From bc1eb3e6c3c47aaa9b811cbfee9af4f59fd0e501 Mon Sep 17 00:00:00 2001 From: Scott Lougheed Date: Thu, 9 Jul 2026 15:29:37 -0700 Subject: [PATCH 01/10] Add MCP integration and Developer Environments skill to unified plugin. Wire the 1Password desktop MCP server into the Cursor plugin manifest, add branding assets, document installation and usage in the README, and ship the 1password-environments agent skill with reference material for import-and-mount workflows. --- .cursor-plugin/plugin.json | 18 ++-- .gitignore | 16 +++ README.md | 105 +++++++++++++++++-- assets/icon.svg | 23 +++++ assets/logo-dark.png | Bin 0 -> 4054 bytes assets/logo-light.png | Bin 0 -> 5488 bytes mcp.json | 8 ++ skills/1password-environments/SKILL.md | 113 +++++++++++++++++++++ skills/1password-environments/reference.md | 70 +++++++++++++ 9 files changed, 339 insertions(+), 14 deletions(-) create mode 100644 .gitignore create mode 100644 assets/icon.svg create mode 100644 assets/logo-dark.png create mode 100644 assets/logo-light.png create mode 100644 mcp.json create mode 100644 skills/1password-environments/SKILL.md create mode 100644 skills/1password-environments/reference.md diff --git a/.cursor-plugin/plugin.json b/.cursor-plugin/plugin.json index 0e3e1b2..68da9ed 100644 --- a/.cursor-plugin/plugin.json +++ b/.cursor-plugin/plugin.json @@ -1,11 +1,12 @@ { "name": "1password", - "version": "1.1.0", - "description": "1Password plugin for Cursor — securely manage development secrets.", + "displayName": "1Password", + "version": "1.2.0", + "description": "1Password plugin for Cursor — hooks, bundled agent skill, and MCP config for Developer Environments and local .env mounts. Secret values stay in 1Password.", "author": { "name": "1Password" }, - "homepage": "https://1password.com", + "homepage": "https://www.1password.dev/", "repository": "https://github.com/1Password/cursor-plugin", "license": "MIT", "keywords": [ @@ -16,8 +17,13 @@ "env", "dotenv", "hooks", - "validation" + "validation", + "mcp", + "developer-environments" ], - "logo": "assets/logo.svg", - "hooks": "hooks/hooks.json" + "logo": "assets/icon.svg", + "hooks": "hooks/hooks.json", + "skills": "./skills/", + "rules": "rules/", + "mcpServers": "./mcp.json" } diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..95ed20e --- /dev/null +++ b/.gitignore @@ -0,0 +1,16 @@ +.DS_Store + +# Environment files (keep templates committable) +.env +.env.* +!.env.example +!.env.sample +!.env.template +!.env.dist + +# Python +__pycache__/ +*.py[cod] + +# Local dev hook wiring (use when team policy blocks local plugin imports) +.cursor/hooks.json diff --git a/README.md b/README.md index ffb5555..b68c060 100644 --- a/README.md +++ b/README.md @@ -1,15 +1,21 @@ # 1Password Plugin for Cursor -The official [1Password](https://1password.com) plugin for [Cursor](https://cursor.com). It brings 1Password's secret management capabilities directly into your editor, helping you develop securely without leaving your workflow. +The official [1Password](https://1password.com) plugin for [Cursor](https://cursor.com). It ships three pieces that work together: **hooks** that validate locally mounted `.env` files, an **agent skill** with the complete Developer Environment workflow, and **MCP configuration** for the 1Password desktop app server. Secret values stay in 1Password — the agent sees variable names and mount paths, not secret contents. + +Install the **plugin** (not a hand-configured MCP entry alone). The bundled `1password-environments` skill and rule are the authoritative agent workflow; the MCP server's built-in documentation resources cover tool basics only and omit import-and-mount steps. For more on 1Password's developer tools, see the [1Password Developer Documentation](https://developer.1password.com). ## Requirements - [1Password](https://1password.com) subscription -- [1Password for Mac or Linux](https://1password.com/downloads) +- [1Password for Mac](https://1password.com/downloads) (this plugin targets **macOS**) - [Cursor](https://cursor.com) -- [sqlite3](https://www.sqlite.org/) installed and available in your `PATH` (pre-installed on macOS; install via your package manager on Linux) + +Additional requirements by feature: + +- **Hooks** — [sqlite3](https://www.sqlite.org/) installed and available in your `PATH` (pre-installed on macOS; install via your package manager on Linux) +- **MCP** — 1Password Labs **MCP Server** experiment enabled in the desktop app (`onepassword://settings/labs`). If the setting is missing, your account may not have the `ai-local-mcp-server` feature flag. > **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. @@ -24,7 +30,7 @@ Before using this plugin, you'll need to configure your secrets in 1Password: ### Step 2: Install the plugin -Install from the [Cursor Marketplace](https://cursor.com/marketplace): +Install from the [Cursor Marketplace](https://cursor.com/marketplace). This registers hooks, the `1password-environments` agent skill, and `mcp.json` together. Do not add the 1Password MCP server manually in user settings instead of installing the plugin — agents will get MCP tools without the skill workflow. 1. Open **Cursor Settings** > **Plugins**. 2. Search for **1password**. @@ -32,6 +38,24 @@ Install from the [Cursor Marketplace](https://cursor.com/marketplace): Or use the command palette: `Ctrl+Shift+P` (or `Cmd+Shift+P` on macOS) > **Plugins: Install Plugin** > search for `1password`. +Or install directly: + +``` +/add-plugin 1password +``` + +### Step 3: Enable MCP in 1Password (required for Environment management) + +Enable the **MCP Server** experiment in the 1Password desktop app: open **Settings → Labs** (or use `onepassword://settings/labs`) and turn on **MCP Server**. The plugin's `mcp.json` connects Cursor to that server after this step. + +The MCP server binary on macOS: + +```text +/Applications/1Password.app/Contents/MacOS/1password-mcp +``` + +On Linux, see the [1Password MCP server documentation](https://www.1password.dev/environments/mcp-server) for the binary path on your platform. + ## Features ### Hooks @@ -40,7 +64,7 @@ Or use the command palette: `Ctrl+Shift+P` (or `Cmd+Shift+P` on macOS) > **Plugi Validates locally mounted `.env` files from [1Password Environments](https://developer.1password.com/docs/environments) before any shell command executes. When required environment files are missing, disabled, or misconfigured, the hook blocks execution and surfaces actionable error messages so the Cursor Agent can guide you to a fix. -This hook was originally developed in the [1Password Cursor Hooks](https://github.com/1Password/cursor-hooks) repository. For the full setup guide, see [Validate local `.env` files with Cursor Agent](https://developer.1password.com/docs/environments/cursor-hook-validate/). +This hook was originally developed in the [1Password Agent Hooks](https://github.com/1Password/agent-hooks) repository. For the full setup guide, see [Validate local `.env` files with Cursor Agent](https://developer.1password.com/docs/environments/cursor-hook-validate/). **How it works:** @@ -122,23 +146,87 @@ DEBUG=1 echo '{"command": "echo test", "workspace_roots": ["/path/to/your/projec When not running in debug mode, the hook writes logs to `/tmp/1password-cursor-hooks.log`. Log entries include timestamps and details about 1Password queries, validation results, and permission decisions. +### MCP and agent skill + +The plugin connects Cursor to the local 1Password MCP server and bundles the **`1password-environments`** skill and **rule** (`skills/1password-environments/SKILL.md`, `rules/1password-environments.mdc`). Agents MUST read that skill before calling MCP tools. + +**Skill-read gate:** a `beforeMCPExecution` hook (`scripts/require-1password-skill-read`) blocks 1Password MCP calls until the agent has Read the **entire** bundled skill in the current conversation. `scripts/mark-1password-skill-read` records that read from the `beforeReadFile` / `preToolUse` Read events (partial reads with `limit` / `offset` / line ranges do not count). + +**Read-deny gate:** a `beforeReadFile` / `preToolUse` hook (`scripts/deny-1password-docs-read`) denies Read on 1Password MCP descriptors under `mcps/` and `FetchMcpResource` for `1password://docs/*`, redirecting to the skill. It fails open so a hook error never blocks a legitimate read. + +**Fail-open enforcement:** the skill-read and read-deny hooks use `failClosed: false`. If a hook script errors or the payload lacks a conversation id, MCP is allowed rather than deadlocked. This plugin targets **macOS** only. + +A **`postToolUse` / `stop` hook** (`scripts/nudge-1password-import`) injects the remaining import steps when an agent stops after `append_variables` without mounting — the common failure mode for `.env` imports. + +The MCP server exposes `1password://docs/getting-started` and `1password://docs/environments-guide`, but those resources are **not** sufficient for agent workflows — they omit importing a plain `.env` file and mounting at the source path by default. The bundled skill defines the complete workflow, including import, append, and mount. + +#### Example prompts + +- "List my 1Password Environments" +- "Mount my staging Environment as `.env` in this repo" +- "What variables are in my production Environment?" +- "Create a new Environment called `my-app-dev`" +- "Create an Environment from my project `.env` file" +- "Import `.env` into 1Password and mount it here" +- "Add a placeholder for my OpenAI API key" + +#### MCP tools + +| Tool | Description | +|------|-------------| +| `authenticate` | Authenticate with the 1Password desktop app; returns `accountId` | +| `list_environments` | List Developer Environments for an account | +| `create_environment` | Create a new Developer Environment | +| `rename_environment` | Rename an existing Developer Environment | +| `list_variables` | List variable names in an Environment (no values) | +| `append_variables` | Add or update Environment variables | +| `create_local_env_file` | Mount an Environment as a local `.env` file | +| `list_local_env_files` | List existing local `.env` mounts for an Environment | + +Confirm the MCP server is connected in **Cursor Settings → MCP** after installing the plugin and enabling the Labs experiment in 1Password. + ## Plugin Structure ``` -1password/ +cursor-plugin/ ├── .cursor-plugin/ │ └── plugin.json # Plugin manifest ├── hooks/ │ └── hooks.json # Hook event configuration +├── skills/ +│ └── 1password-environments/ +│ ├── SKILL.md # Agent skill for MCP workflows +│ └── reference.md # Mount conflict and troubleshooting +├── rules/ +│ └── 1password-environments.mdc # Agent rule (activates on 1Password MCP work) +├── mcp.json # MCP server configuration ├── assets/ -│ └── logo.svg # Plugin logo +│ ├── logo.svg # Plugin logo +│ └── icon.svg ├── scripts/ +│ ├── lib/ +│ │ ├── hook_state.py # Shared conversation state for Python hooks +│ │ └── telemetry.sh # Shared opt-in telemetry helpers │ ├── validate-mounted-env-files # Bash hook (macOS / Linux) -│ └── validate-mounted-env-files.cmd # Windows cmd wrapper returns allow (validation skipped) +│ ├── validate-mounted-env-files.cmd # Windows cmd wrapper returns allow (validation skipped) +│ ├── deny-1password-docs-read # Blocks Read on 1Password MCP descriptors under mcps/ +│ ├── mark-1password-skill-read # Records skill read for MCP gate +│ ├── require-1password-skill-read # Blocks 1Password MCP until skill read +│ └── nudge-1password-import # Nudges agents to finish .env import + mount ├── LICENSE └── README.md ``` +## Local development + +Symlink the repository root for local testing: + +```bash +ln -s /path/to/cursor-plugin ~/.cursor/plugins/local/1password +``` + +Reload Cursor after creating or updating the symlink. + ## Telemetry The plugin emits **opt-in** telemetry so 1Password can understand plugin adoption and the prevalence of common failure modes (missing files, disabled mounts). Two event types are emitted: @@ -159,6 +247,7 @@ The plugin emits **opt-in** telemetry so 1Password can understand plugin adoptio ## Resources - [Validate local `.env` files with Cursor Agent](https://developer.1password.com/docs/environments/cursor-hook-validate/) — full setup guide on the 1Password Developer site +- [1Password MCP server documentation](https://www.1password.dev/environments/mcp-server) - [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 diff --git a/assets/icon.svg b/assets/icon.svg new file mode 100644 index 0000000..eda328f --- /dev/null +++ b/assets/icon.svg @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/logo-dark.png b/assets/logo-dark.png new file mode 100644 index 0000000000000000000000000000000000000000..9f83808a9703edf8c7ee3a8af2e520fcc919de80 GIT binary patch literal 4054 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 literal 0 HcmV?d00001 diff --git a/assets/logo-light.png b/assets/logo-light.png new file mode 100644 index 0000000000000000000000000000000000000000..d167b26fe482156ca1f0e97d32b396b93821bed3 GIT binary patch literal 5488 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 L=I4v`NH_lrQ62I1 literal 0 HcmV?d00001 diff --git a/mcp.json b/mcp.json new file mode 100644 index 0000000..90b8b4e --- /dev/null +++ b/mcp.json @@ -0,0 +1,8 @@ +{ + "mcpServers": { + "1password": { + "command": "/Applications/1Password.app/Contents/MacOS/1password-mcp", + "args": [] + } + } +} diff --git a/skills/1password-environments/SKILL.md b/skills/1password-environments/SKILL.md new file mode 100644 index 0000000..2badb00 --- /dev/null +++ b/skills/1password-environments/SKILL.md @@ -0,0 +1,113 @@ +--- +name: 1password-environments +description: >- + MANDATORY — read this ENTIRE file before any 1Password MCP call. Canonical + workflow for 1Password Developer Environments in Cursor. Use when creating, + importing, migrating, or mounting .env files; managing repo secrets; listing + Environment variable names; or calling any 1password MCP tool. Import-from-.env + ALWAYS includes create_local_env_file at the source path unless the user + explicitly says not to mount. MCP calls are blocked by plugin hooks until this + file has been read. +--- + +# 1Password Environments + +## Read this first + +If you have not read **every section** of this file in the current conversation, +stop. A plugin hook blocks 1Password MCP tools until you read this skill with +the Read tool. Do not substitute MCP docs, tool descriptor JSON under `mcps/`, +or memory from other sessions. + +## Not done until + +**Import / create from `.env`** (including "using values from the project `.env`"): + +- [ ] Environment created or resolved +- [ ] Variables appended via `append_variables` +- [ ] `create_local_env_file` at the **source** `.env` absolute path — **always** +- [ ] Mount verified with `list_local_env_files` + +Mounting at the source `.env` path is mandatory, not optional follow-up. The +**only** exception is the user explicitly opting out ("without mounting", "do not +mount", "skip the mount"). Never ask "want me to mount?" — just mount. + +Stopping after `create_environment` + `append_variables` is **incomplete and wrong**. +`list_variables` is not mount verification. Do not report success until the mount +checklist is done. + +**Mount only:** mount exists at the requested path (`list_local_env_files`). + +## Do not + +- Read files under `mcps/**/tools/*.json` — call MCP tools directly +- Fetch `1password://docs/getting-started` or `environments-guide` — they omit import-and-mount; this skill is the workflow +- Skip `create_local_env_file` on import unless the user explicitly opted out +- Report success before the import checklist (including mount) is complete +- Offer mounting as optional follow-up — it is mandatory on import + +## MCP tools + +Request params are **camelCase**; responses may use snake_case. Get `accountId` +from `authenticate`; get `environmentId` from `create_environment` / +`list_environments`. Pass **every** required param — omitting one fails with +`missing field ''`. + +| Tool | Required params | Returns / notes | +|------|-----------------|-----------------| +| `authenticate` | *(none)* | `accountId`. First call each turn. | +| `list_environments` | `accountId` | Environments to resolve by name. | +| `create_environment` | `accountId`, `environmentName` | `environmentId`. Import continues below. | +| `rename_environment` | `accountId`, `environmentId`, `environmentName` | `environmentName` is the **new** name. | +| `list_variables` | `accountId`, `environmentId` | Names only — never values. | +| `append_variables` | `accountId`, `environmentId`, `variables: [{ name, value, concealed }]` | `variables` is an **array**; secrets `concealed: true`. | +| `list_local_env_files` | `accountId`, `environmentId` | Existing mounts. | +| `create_local_env_file` | `accountId`, `environmentId`, `environmentName`, `mountPath` | `mountPath` = absolute source `.env` path (macOS/Linux). | + +## Import from a `.env` file + +Default path: `{workspace_root}/.env` unless the user names another path. + +1. **Read** the `.env` file with the Read tool to get its keys and values. Strip optional surrounding quotes from values. Pass values to MCP only — never paste secret values into chat. +2. **`authenticate`** → `accountId` +3. **`create_environment`** (new name) or **`list_environments`** (existing) +4. **`append_variables`** with all variables (secrets `concealed: true`) +5. **Mount** — **always** (skip only if the user explicitly said not to mount): + - `list_local_env_files` — skip `create_local_env_file` only if a mount already exists at the source path + - `create_local_env_file` with `accountId`, `environmentId`, `environmentName`, `mountPath` (absolute path of the original `.env`) + - `list_local_env_files` again to verify + +If shell is blocked because 1Password already expects a mount at the path, see [reference.md](reference.md) (mount conflict). + +## Other flows + +**Mount existing Environment:** authenticate → resolve Environment → step 5 above. + +**Inspect names:** authenticate → resolve → `list_variables` → summarize names only. + +**Rename:** authenticate → resolve → confirm name → `rename_environment`. + +**Add/update variables:** authenticate → resolve → `list_variables` → `append_variables`. + +## Safety + +- Never reveal secret values in chat +- Store secrets with `concealed: true` +- Ask before changing variables unless the request is explicit +- A plain source `.env` is fine to Read. But once an environment is mounted, that path becomes a live FIFO (named pipe) — do not Read a mounted FIFO path (reading it can consume/hang the pipe); use `list_variables` to inspect a mounted environment instead + +## Plugin enforcement + +This plugin enforces the workflow with hooks: + +| Hook | Effect | +|------|--------| +| `beforeMCPExecution` | Blocks 1Password MCP until this skill is Read in full | +| `postToolUse` / `stop` | Nudges mount steps after `append_variables` | +| `beforeReadFile` / `preToolUse` | Blocks Read on 1Password MCP descriptors under `mcps/` and `FetchMcpResource` for `1password://docs/*` — read this skill instead | + +Treat hook `additional_context`, `agent_message`, and `followup_message` output as mandatory — not suggestions. + +## Troubleshooting + +Mount conflict, validation modes, setup: [reference.md](reference.md) diff --git a/skills/1password-environments/reference.md b/skills/1password-environments/reference.md new file mode 100644 index 0000000..a725d5f --- /dev/null +++ b/skills/1password-environments/reference.md @@ -0,0 +1,70 @@ +# 1Password Environments — reference + +## Skill-read gate (plugin hook) + +`scripts/require-1password-skill-read` runs on `beforeMCPExecution`. It **blocks** +1Password MCP until the agent has Read `skills/1password-environments/SKILL.md` in +the current conversation. `scripts/mark-1password-skill-read` records that read on +`beforeReadFile` / `preToolUse` Read. Partial reads (`limit`, `offset`, or line +ranges) do not satisfy the gate — the agent must read the entire skill file. + +If MCP is denied with a skill-read message, Read the skill file end-to-end, then +retry the MCP call. Do not substitute MCP docs or `mcps/**/tools/*.json`. + +## Read gate (plugin hook) + +`scripts/deny-1password-docs-read` runs on `beforeReadFile` / `preToolUse` and denies +Read on 1Password MCP descriptors and docs under `mcps/**/*1password*/**`, plus +`FetchMcpResource` for `1password://docs/*`, redirecting to `SKILL.md`. Call the MCP +tools directly per this skill. It fails open (`failClosed: false`) so a hook error +never blocks a legitimate read. + +## Import completion nudges (plugin hook) + +The plugin runs `scripts/nudge-1password-import` after relevant 1Password MCP calls and on agent stop. If you appended variables during an import but have not called `create_local_env_file`, the hook injects the next required steps. Treat that as mandatory — do not reply to the user until the mount is verified. + +## Mount conflict (shell validation hook) + +The `beforeShellExecution` hook (`scripts/validate-mounted-env-files`) blocks **all** +shell commands when 1Password expects a mount at a path that is missing, disabled, or +not a FIFO (for example a plain `.env` still on disk at the mount path). Reading the +`.env` with the Read tool is unaffected — only shell commands are gated. + +Validation scope depends on `.1password/environments.toml`: + +- No TOML file (or no `mount_paths` field) — default mode: all 1Password mount destinations for this workspace are validated. +- `mount_paths = [".env", ...]` — only listed paths are validated. +- `mount_paths = []` — validation disabled for this repo; all shell commands allowed. + +If shell commands are blocked with a message about missing, invalid, or disabled environment files: + +1. Check whether 1Password already has a destination for the same path (`list_local_env_files`, or the 1Password app Destinations tab). +2. Resolve it by one of: + - Temporarily set `mount_paths = []` in `.1password/environments.toml` to disable mount validation for this repo. + - Fix the mount in 1Password (enable the destination, or remove it until migration finishes). + +The import itself does not need shell — Read the `.env` directly to get its keys and values. + +## Setup and troubleshooting + +**Requirements** + +- macOS or Linux with the 1Password desktop app installed (local `.env` mounts are macOS/Linux only; on Windows the validation hook is a no-op). +- **1Password Cursor plugin installed** (marketplace or local symlink) so this skill, hooks, and MCP config load together. +- 1Password Labs **MCP Server** experiment enabled in the desktop app (`onepassword://settings/labs`). +- Access to a 1Password account with Developer Environments enabled. + +The MCP server binary on macOS: + +```text +/Applications/1Password.app/Contents/MacOS/1password-mcp +``` + +On Linux, see the [1Password MCP server documentation](https://www.1password.dev/environments/mcp-server) for the binary path on your platform. + +**When things fail** + +- Authentication or environment access fails — the 1Password desktop app may need approval, unlocking, or account access. +- MCP server unavailable — enable the **1Password Labs MCP Server** experiment via `onepassword://settings/labs`. If the Labs setting is missing, the account may not have the required `ai-local-mcp-server` feature flag. +- `create_local_env_file` fails — confirm the user is on macOS or Linux. +- Shell commands denied while 1Password expects a mount that is missing or disabled — see **Mount conflict** above. From 081607a7c4592c6856307a2c410b5d1c66dea21e Mon Sep 17 00:00:00 2001 From: Scott Lougheed Date: Thu, 9 Jul 2026 15:29:38 -0700 Subject: [PATCH 02/10] Add agent hooks and rules for environment workflow guardrails. Block direct .env and MCP doc reads that bypass the skill, nudge agents toward MCP-based imports, and require reading the skill before 1Password MCP tool calls. --- hooks/hooks.json | 39 ++++ rules/1password-environments.mdc | 25 +++ scripts/deny-1password-docs-read | 124 +++++++++++ scripts/lib/hook_state.py | 311 +++++++++++++++++++++++++++ scripts/lib/telemetry.sh | 22 +- scripts/mark-1password-skill-read | 79 +++++++ scripts/nudge-1password-import | 225 +++++++++++++++++++ scripts/require-1password-skill-read | 76 +++++++ scripts/test_hook_state.py | 124 +++++++++++ 9 files changed, 1006 insertions(+), 19 deletions(-) create mode 100644 rules/1password-environments.mdc create mode 100755 scripts/deny-1password-docs-read create mode 100644 scripts/lib/hook_state.py create mode 100755 scripts/mark-1password-skill-read create mode 100755 scripts/nudge-1password-import create mode 100755 scripts/require-1password-skill-read create mode 100644 scripts/test_hook_state.py diff --git a/hooks/hooks.json b/hooks/hooks.json index d57cfee..5d23192 100644 --- a/hooks/hooks.json +++ b/hooks/hooks.json @@ -1,10 +1,49 @@ { "version": 1, "hooks": { + "beforeMCPExecution": [ + { + "command": "./scripts/require-1password-skill-read", + "matcher": "1password|CallMcpTool|authenticate|list_environments|create_environment|rename_environment|list_variables|append_variables|create_local_env_file|list_local_env_files", + "failClosed": false + } + ], "beforeShellExecution": [ { "command": "./scripts/validate-mounted-env-files" } + ], + "preToolUse": [ + { + "command": "./scripts/mark-1password-skill-read", + "matcher": "Read File V2|Read" + }, + { + "command": "./scripts/deny-1password-docs-read", + "matcher": "Read File V2|Read|FetchMcpResource", + "failClosed": false + } + ], + "beforeReadFile": [ + { + "command": "./scripts/mark-1password-skill-read" + }, + { + "command": "./scripts/deny-1password-docs-read", + "failClosed": false + } + ], + "postToolUse": [ + { + "command": "./scripts/nudge-1password-import", + "matcher": "CallMcpTool|MCP:.*1password|MCP:append_variables|MCP:create_environment|MCP:create_local_env_file|MCP:list_variables|MCP:authenticate" + } + ], + "stop": [ + { + "command": "./scripts/nudge-1password-import", + "loop_limit": 3 + } ] } } diff --git a/rules/1password-environments.mdc b/rules/1password-environments.mdc new file mode 100644 index 0000000..fa5dfa3 --- /dev/null +++ b/rules/1password-environments.mdc @@ -0,0 +1,25 @@ +--- +description: >- + 1Password Developer Environments — read skills/1password-environments/SKILL.md + before any 1Password MCP call. Import, mount, or manage repo .env secrets via the + 1password MCP server. Apply on 1Password, Developer Environment, .env import, + migrate, mount, append_variables, create_local_env_file, or project secrets. +alwaysApply: true +--- + +# 1Password Environments (Cursor plugin) + +**Before your first 1Password MCP call in this conversation:** Read +`skills/1password-environments/SKILL.md` in full with the Read tool. Plugin hooks +block MCP until that file is read. Do not read MCP tool descriptors under `mcps/`. +Do not fetch `1password://docs/getting-started` or `environments-guide`. + +`skills/1password-environments/SKILL.md` is the single source of truth for the full +workflow — the step-by-step import sequence, exact MCP tool parameters, the mount +checklist, and mount-conflict handling. Follow it rather than working from memory; +this rule only points you there. + +The one thing worth repeating up front: **an import is not done after +`append_variables`.** You must `create_local_env_file` at the source `.env` path and +verify it with `list_local_env_files`. `list_variables` does not verify a mount, and +mounting is never an optional follow-up. diff --git a/scripts/deny-1password-docs-read b/scripts/deny-1password-docs-read new file mode 100755 index 0000000..e6bf2da --- /dev/null +++ b/scripts/deny-1password-docs-read @@ -0,0 +1,124 @@ +#!/usr/bin/env python3 +"""Block Read/Fetch access to 1Password MCP descriptors and docs. + +The 1password-environments skill is the single source of truth for the workflow. +Agents that have not read it tend to spelunk raw MCP tool/resource JSON under +`mcps/**/*1password*/**` or fetch `1password://docs/*`, which wastes turns and +skips the mount checklist. Deny those reads and redirect to the bundled skill. +""" + +from __future__ import annotations + +import json +import os +import re +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) + +from lib.hook_state import ( # noqa: E402 + is_onepassword_mcp_doc_fetch, + is_onepassword_mcp_doc_path, + parse_tool_input, + skill_path_hint, +) + +_READ_TOOL_NAME = re.compile(r"^Read(?: File V2)?$", re.IGNORECASE) +_FETCH_TOOL_NAME = re.compile(r"^FetchMcpResource$", re.IGNORECASE) + + +def _debug(message: str) -> None: + if os.environ.get("DEBUG"): + with open("/tmp/1password-cursor-hooks.log", "a", encoding="utf-8") as log: + log.write(f"deny-1password-docs-read: {message}\n") + + +def _file_path_from_payload(data: dict) -> str: + path = data.get("file_path") + if isinstance(path, str) and path: + return path + + tool_input = parse_tool_input(data) + for key in ("path", "targetFile", "file_path", "filePath"): + value = tool_input.get(key) + if isinstance(value, str) and value: + return value + + return "" + + +def _is_guarded_event(data: dict) -> bool: + if data.get("hook_event_name") == "beforeReadFile": + return True + + tool_name = data.get("tool_name") + if isinstance(tool_name, str): + stripped = tool_name.strip() + if _READ_TOOL_NAME.match(stripped) or _FETCH_TOOL_NAME.match(stripped): + return True + + if data.get("file_path") and not tool_name: + return True + + return False + + +def _should_deny(data: dict) -> bool: + file_path = _file_path_from_payload(data) + if file_path and is_onepassword_mcp_doc_path(file_path): + return True + return is_onepassword_mcp_doc_fetch(data) + + +def _deny(data: dict) -> None: + message = ( + "Do not read 1Password MCP descriptors or docs under `mcps/`. " + f"{skill_path_hint(data)} " + "It is the single source of truth for the workflow and exact tool params. " + "Do not fetch `1password://docs/getting-started` or `environments-guide` either. " + "After reading the skill, call the 1Password MCP tools directly." + ) + print( + json.dumps( + { + "permission": "deny", + "user_message": "Read the 1password-environments plugin skill instead of the raw MCP descriptors.", + "agent_message": message, + } + ) + ) + sys.exit(2) + + +def _allow() -> None: + print(json.dumps({"permission": "allow"})) + + +def main() -> None: + raw = sys.stdin.read() + if not raw.strip(): + _allow() + return + + try: + data = json.loads(raw) + except json.JSONDecodeError: + _allow() + return + + _debug(json.dumps({"event": data.get("hook_event_name"), "tool_name": data.get("tool_name")})) + + if not _is_guarded_event(data): + _allow() + return + + if _should_deny(data): + _debug(f"deny {_file_path_from_payload(data) or 'fetch'}") + _deny(data) + + _allow() + + +if __name__ == "__main__": + main() diff --git a/scripts/lib/hook_state.py b/scripts/lib/hook_state.py new file mode 100644 index 0000000..619a1eb --- /dev/null +++ b/scripts/lib/hook_state.py @@ -0,0 +1,311 @@ +"""Shared conversation state for 1Password Cursor plugin hooks.""" + +from __future__ import annotations + +import json +import re +from pathlib import Path + +STATE_DIR = Path("/tmp/1password-cursor-plugin") +IMPORT_STATE_DIR = STATE_DIR / "import-state" +SKILL_READ_DIR = STATE_DIR / "skill-read" + +_SKILL_MARKERS = ( + "skills/1password-environments/skill.md", + "1password-environments/skill.md", +) + +_ONEPASSWORD_MCP_TOOLS = frozenset( + { + "authenticate", + "list_environments", + "create_environment", + "rename_environment", + "list_variables", + "append_variables", + "create_local_env_file", + "list_local_env_files", + } +) + +_MCP_TOOL_WRAPPERS = frozenset({"callmcptool", "call_mcp_tool"}) +_MCP_FETCH_WRAPPERS = frozenset({"fetchmcpresource", "fetch_mcp_resource"}) + +_ONEPASSWORD_SERVER = re.compile(r"1password", re.IGNORECASE) +_MCP_ONEPASSWORD_PATH = re.compile(r"/mcps/[^/]*1password[^/]*/", re.IGNORECASE) +_ONEPASSWORD_DOC_URI = re.compile( + r"^1password://docs/(?:getting-started|environments-guide)", + re.IGNORECASE, +) + +_CONVERSATION_ID_KEYS = ( + "conversation_id", + "session_id", + "chat_id", + "thread_id", + "generation_id", +) + + +def parse_tool_input(data: dict) -> dict: + raw = data.get("tool_input") + if isinstance(raw, dict): + return raw + if isinstance(raw, str) and raw.strip(): + try: + parsed = json.loads(raw) + if isinstance(parsed, dict): + return parsed + except json.JSONDecodeError: + pass + return {} + + +def conversation_id(data: dict) -> str: + for key in _CONVERSATION_ID_KEYS: + value = data.get(key) + if value is not None and str(value).strip(): + return str(value) + return "" + + +def can_enforce_skill_gate(data: dict) -> bool: + """Return False when no stable conversation key is available. + + Without one, deny would deadlock because mark-1password-skill-read cannot + persist state. Fail open in that case. + """ + return bool(conversation_id(data)) + + +def _safe_id(value: str) -> str: + return re.sub(r"[^A-Za-z0-9._-]", "_", value or "unknown") + + +def skill_read_marker_path(conv_id: str) -> Path: + return SKILL_READ_DIR / f"{_safe_id(conv_id)}.json" + + +def is_skill_read(conv_id: str) -> bool: + if not conv_id: + return False + path = skill_read_marker_path(conv_id) + if not path.is_file(): + return False + try: + payload = json.loads(path.read_text(encoding="utf-8")) + return bool(payload.get("skill_read")) + except (OSError, json.JSONDecodeError): + return False + + +def mark_skill_read(conv_id: str) -> None: + if not conv_id: + return + SKILL_READ_DIR.mkdir(parents=True, exist_ok=True) + skill_read_marker_path(conv_id).write_text( + json.dumps({"skill_read": True}, indent=2), + encoding="utf-8", + ) + + +def skill_paths_for_workspace(workspace_root: str) -> list[str]: + paths: list[str] = [] + if workspace_root: + workspace_skill = ( + Path(workspace_root) / "skills" / "1password-environments" / "SKILL.md" + ) + if workspace_skill.is_file(): + paths.append(str(workspace_skill)) + home = Path.home() + paths.extend( + str(path) + for path in ( + home / ".cursor" / "plugins" / "local" / "1password" / "skills" / "1password-environments" / "SKILL.md", + home / ".cursor" / "plugins" / "cache" / "1password" / "skills" / "1password-environments" / "SKILL.md", + ) + if path.is_file() + ) + cache_root = home / ".cursor" / "plugins" / "cache" + if cache_root.is_dir(): + for skill_path in cache_root.glob("**/skills/1password-environments/SKILL.md"): + resolved = str(skill_path) + if resolved not in paths: + paths.append(resolved) + return paths + + +def skill_path_hint(data: dict) -> str: + """Absolute-path instruction for reading the bundled skill.""" + workspace_roots = data.get("workspace_roots") + workspace_root = workspace_roots[0] if isinstance(workspace_roots, list) and workspace_roots else "" + candidates = skill_paths_for_workspace(workspace_root) + if candidates: + paths = "\n".join(f"- `{path}`" for path in candidates) + return f"Read the ENTIRE file at the first path that exists:\n{paths}" + return ( + "Read the ENTIRE `1password-environments` plugin skill " + "(`skills/1password-environments/SKILL.md` in the plugin root)." + ) + + +def skill_read_instruction(data: dict) -> str: + path_hint = skill_path_hint(data) + + return ( + "1Password MCP calls are blocked until you read the bundled plugin skill. " + f"{path_hint} " + "Do not read MCP tool descriptors under `mcps/`. " + "Do not fetch `1password://docs/getting-started` or `environments-guide`. " + "After reading the skill, retry the MCP call and follow the import checklist " + "(append_variables is not done until create_local_env_file is verified)." + ) + + +def is_skill_markdown_path(path: str) -> bool: + normalized = path.replace("\\", "/").lower() + return any(marker in normalized for marker in _SKILL_MARKERS) + + +def _coerce_int(value: object) -> int | None: + if value is None: + return None + if isinstance(value, bool): + return None + if isinstance(value, int): + return value + if isinstance(value, float): + return int(value) + if isinstance(value, str) and value.strip().lstrip("-").isdigit(): + return int(value.strip()) + return None + + +def is_full_skill_read(data: dict) -> bool: + """Return True only when the Read appears to cover the entire skill file.""" + tool_input = parse_tool_input(data) + + if _coerce_int(tool_input.get("limit")) is not None: + return False + + offset = _coerce_int(tool_input.get("offset")) + if offset is not None and offset > 0: + return False + + for key in ("startLine", "lineStart", "start_line"): + start = _coerce_int(tool_input.get(key)) + if start is not None and start > 1: + return False + + for key in ("endLine", "lineEnd", "end_line"): + if _coerce_int(tool_input.get(key)) is not None: + return False + + return True + + +def is_1password_server(server: str) -> bool: + return bool(server and _ONEPASSWORD_SERVER.search(server)) + + +def normalize_tool_name(name: str) -> str: + lowered = name.strip().lower() + if lowered.startswith("mcp:"): + lowered = lowered[4:] + if "1password" in lowered and "-" in lowered: + return lowered.rsplit("-", 1)[-1] + return lowered + + +def tool_name(data: dict) -> str: + for key in ("tool_name", "toolName"): + value = data.get(key) + if isinstance(value, str) and value: + return value + return "" + + +def mcp_tool_slug(data: dict) -> str: + """Return the 1Password MCP tool slug when this payload is a 1Password MCP call.""" + name = tool_name(data) + if not name: + return "" + + lowered = name.strip().lower() + tool_input = parse_tool_input(data) + + if lowered in _MCP_TOOL_WRAPPERS: + server = str( + tool_input.get("server") + or tool_input.get("mcpServer") + or tool_input.get("mcp_server") + or "" + ) + if not is_1password_server(server): + return "" + slug = str( + tool_input.get("toolName") + or tool_input.get("tool_name") + or tool_input.get("name") + or "" + ).strip() + return normalize_tool_name(slug) if slug else "" + + if "1password" in lowered or "user-1password" in lowered: + slug = normalize_tool_name(name) + return slug if slug in _ONEPASSWORD_MCP_TOOLS else "" + + slug = normalize_tool_name(name) + return slug if slug in _ONEPASSWORD_MCP_TOOLS else "" + + +def mcp_tool_arguments(data: dict) -> dict: + tool_input = parse_tool_input(data) + name = tool_name(data).strip().lower() + + if name in _MCP_TOOL_WRAPPERS: + args = ( + tool_input.get("arguments") + or tool_input.get("args") + or tool_input.get("parameters") + or {} + ) + if isinstance(args, str) and args.strip(): + try: + parsed = json.loads(args) + if isinstance(parsed, dict): + return parsed + except json.JSONDecodeError: + return {} + return args if isinstance(args, dict) else {} + + return tool_input + + +def is_1password_mcp_call(data: dict) -> bool: + slug = mcp_tool_slug(data) + if not slug: + return False + return slug in _ONEPASSWORD_MCP_TOOLS + + +def is_onepassword_mcp_doc_path(path: str) -> bool: + normalized = path.replace("\\", "/") + return bool(_MCP_ONEPASSWORD_PATH.search(normalized)) + + +def is_onepassword_mcp_doc_fetch(data: dict) -> bool: + name = tool_name(data).strip().lower() + if name not in _MCP_FETCH_WRAPPERS: + return False + + tool_input = parse_tool_input(data) + uri = str(tool_input.get("uri") or tool_input.get("url") or "").strip() + if not uri: + return False + + if _ONEPASSWORD_DOC_URI.match(uri): + return True + + normalized = uri.replace("\\", "/") + return bool(_MCP_ONEPASSWORD_PATH.search(normalized)) diff --git a/scripts/lib/telemetry.sh b/scripts/lib/telemetry.sh index 8d64d21..af68d9a 100644 --- a/scripts/lib/telemetry.sh +++ b/scripts/lib/telemetry.sh @@ -59,16 +59,6 @@ get_telemetry_dir() { echo "${HOME}/.config/1Password/data/hook-events" } -# Emit the `,"ts":` JSON fragment stamping when the event was -# produced, or an empty string if the clock is unreadable. -ts_json_fragment() { - local ts - ts=$(current_time_ms) - if [[ "$ts" =~ ^[0-9]+$ ]]; then - printf ',"ts":%s' "$ts" - fi -} - # 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() { @@ -76,7 +66,7 @@ telemetry_consent_enabled() { } # Append a single JSON line to the events.jsonl file. -# Checks consent and enforces a 1MB file size cap (drop-newest). +# Checks consent and enforces a 1MB file size cap. write_telemetry_event() { local json_line="$1" local event_dir @@ -152,11 +142,8 @@ write_execution_event() { local duration_bucket duration_bucket=$(bucket_duration_ms "$duration_ms") - local ts_field - ts_field=$(ts_json_fragment) - 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_bucket\":\"${duration_bucket}\",\"mode\":${mode_json},\"mount_count\":${mount_count_json}${ts_field}}" + 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_bucket\":\"${duration_bucket}\",\"mode\":${mode_json},\"mount_count\":${mount_count_json}}" write_telemetry_event "$json_line" } @@ -173,11 +160,8 @@ write_install_event() { escaped_hook_name=$(escape_json_string "$hook_name") escaped_hook_version=$(escape_json_string "$hook_version") - local ts_field - ts_field=$(ts_json_fragment) - local json_line - json_line="{\"schema\":\"agent_hook_install\",\"client\":\"${escaped_client}\",\"hook_name\":\"${escaped_hook_name}\",\"hook_version\":\"${escaped_hook_version}\",\"install_method\":\"${install_method}\"${ts_field}}" + json_line="{\"schema\":\"agent_hook_install\",\"client\":\"${escaped_client}\",\"hook_name\":\"${escaped_hook_name}\",\"hook_version\":\"${escaped_hook_version}\",\"install_method\":\"${install_method}\"}" write_telemetry_event "$json_line" } diff --git a/scripts/mark-1password-skill-read b/scripts/mark-1password-skill-read new file mode 100755 index 0000000..daf9958 --- /dev/null +++ b/scripts/mark-1password-skill-read @@ -0,0 +1,79 @@ +#!/usr/bin/env python3 +"""Record that the agent read the 1password-environments plugin skill.""" + +from __future__ import annotations + +import json +import os +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) + +from lib.hook_state import ( # noqa: E402 + can_enforce_skill_gate, + conversation_id, + is_full_skill_read, + is_skill_markdown_path, + mark_skill_read, + parse_tool_input, +) + + +def _debug(message: str) -> None: + if os.environ.get("DEBUG"): + with open("/tmp/1password-cursor-hooks.log", "a", encoding="utf-8") as log: + log.write(f"mark-1password-skill-read: {message}\n") + + +def _file_path_from_payload(data: dict) -> str: + path = data.get("file_path") + if isinstance(path, str) and path: + return path + + tool_input = parse_tool_input(data) + for key in ("path", "targetFile", "file_path", "filePath"): + value = tool_input.get(key) + if isinstance(value, str) and value: + return value + + return "" + + +def _allow() -> None: + print(json.dumps({"permission": "allow"})) + + +def main() -> None: + raw = sys.stdin.read() + if not raw.strip(): + _allow() + return + + try: + data = json.loads(raw) + except json.JSONDecodeError: + _allow() + return + + file_path = _file_path_from_payload(data) + if file_path and is_skill_markdown_path(file_path): + if not is_full_skill_read(data): + _debug(f"ignored partial read of {file_path}") + _allow() + return + + if not can_enforce_skill_gate(data): + _debug(f"no conversation id; ignored read of {file_path}") + _allow() + return + + conv_id = conversation_id(data) + _debug(f"marked skill read for {conv_id!r} via {file_path}") + mark_skill_read(conv_id) + + _allow() + + +if __name__ == "__main__": + main() diff --git a/scripts/nudge-1password-import b/scripts/nudge-1password-import new file mode 100755 index 0000000..fb76524 --- /dev/null +++ b/scripts/nudge-1password-import @@ -0,0 +1,225 @@ +#!/usr/bin/env python3 +"""Track 1Password import progress and nudge the agent to finish mounting. + +After append_variables during an import, many agents stop early. This hook +injects additional_context (after MCP) and, if the agent still tries to finish, +a stop-hook followup_message until create_local_env_file clears pending state. +""" + +from __future__ import annotations + +import json +import os +import re +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) + +from lib.hook_state import ( # noqa: E402 + IMPORT_STATE_DIR, + conversation_id, + mcp_tool_arguments, + mcp_tool_slug, + skill_path_hint, + tool_name, +) + +STATE_DIR = IMPORT_STATE_DIR + +APPEND_NUDGE = ( + "1Password import checkpoint: append_variables succeeded but the import is NOT " + "complete. Required next MCP calls in order: (1) list_local_env_files — skip " + "create_local_env_file if the source path is already mounted; (2) " + "create_local_env_file with mountPath set to the source .env absolute path " + "(project root /.env when the user said here or project root); (3) " + "list_local_env_files again to verify. list_variables does NOT verify a mount. " + "Do NOT tell the user the task is done. Do NOT offer mounting as an optional " + "follow-up." +) + +LIST_VARS_NUDGE = ( + "1Password import still incomplete: listing variable names does not create or " + "verify a local mount. If you imported from a .env file, call " + "list_local_env_files then create_local_env_file at the source path, then verify " + "with list_local_env_files before responding to the user." +) + + +def _stop_followup(data: dict) -> str: + return ( + "Continue the 1Password .env import from where you left off. If you have not " + f"already read the plugin skill: {skill_path_hint(data)} " + "Then call list_local_env_files, create_local_env_file at the source .env " + "absolute path for this workspace, and verify with list_local_env_files. Do not " + "reply to the user until the mount is verified or the user explicitly declined " + "mounting." + ) + +def _debug(message: str) -> None: + if os.environ.get("DEBUG"): + STATE_DIR.mkdir(parents=True, exist_ok=True) + with (STATE_DIR / "nudge.log").open("a", encoding="utf-8") as log: + log.write(message + "\n") + + +def _mcp_succeeded(data: dict) -> bool: + # afterMCPExecution payloads expose the result as `result_json`; postToolUse + # payloads expose it as `tool_output`. Check both so the nudge can tell a + # failed call from a successful one regardless of which event fired. + raw = data.get("result_json") + if not isinstance(raw, str) or not raw.strip(): + raw = data.get("tool_output") + if not isinstance(raw, str) or not raw.strip(): + return True + try: + result = json.loads(raw) + except json.JSONDecodeError: + return True + if isinstance(result, dict): + if result.get("error"): + return False + if result.get("isError") is True: + return False + return True + + +def _state_path(conversation_id: str) -> Path: + safe = re.sub(r"[^A-Za-z0-9._-]", "_", conversation_id or "unknown") + return STATE_DIR / f"{safe}.json" + + +def _load_state(conversation_id: str) -> dict: + path = _state_path(conversation_id) + if not path.is_file(): + return {} + try: + data = json.loads(path.read_text(encoding="utf-8")) + return data if isinstance(data, dict) else {} + except (OSError, json.JSONDecodeError): + return {} + + +def _save_state(conversation_id: str, state: dict) -> None: + if not conversation_id: + return + STATE_DIR.mkdir(parents=True, exist_ok=True) + _state_path(conversation_id).write_text(json.dumps(state, indent=2), encoding="utf-8") + + +def _clear_state(conversation_id: str) -> None: + path = _state_path(conversation_id) + try: + path.unlink(missing_ok=True) + except OSError: + pass + + +def _variable_count(tool_input: dict) -> int: + variables = tool_input.get("variables") + return len(variables) if isinstance(variables, list) else 0 + + +def _should_track_mount(state: dict, tool_input: dict) -> bool: + if state.get("created_environment_this_session"): + return True + return _variable_count(tool_input) >= 2 + + +def _emit(additional_context: str | None = None, followup_message: str | None = None) -> None: + payload: dict[str, str] = {} + if additional_context: + payload["additional_context"] = additional_context + if followup_message: + payload["followup_message"] = followup_message + if payload: + print(json.dumps(payload)) + else: + print("{}") + + +def _handle_after_mcp(data: dict) -> None: + conv_id = conversation_id(data) + slug = mcp_tool_slug(data) + if not slug: + _emit() + return + + tool_input = mcp_tool_arguments(data) + + if not _mcp_succeeded(data): + _emit() + return + + state = _load_state(conv_id) + + if slug == "create_environment": + state["created_environment_this_session"] = True + _save_state(conv_id, state) + _emit() + return + + if slug == "create_local_env_file": + _clear_state(conv_id) + _emit() + return + + if slug == "append_variables": + if _should_track_mount(state, tool_input): + state["pending_mount"] = True + workspace_roots = data.get("workspace_roots") + if isinstance(workspace_roots, list) and workspace_roots: + state["workspace_root"] = workspace_roots[0] + for key in ("accountId", "environmentId", "environmentName"): + if key in tool_input: + state[key] = tool_input[key] + _save_state(conv_id, state) + _emit(additional_context=APPEND_NUDGE) + return + _save_state(conv_id, state) + _emit() + return + + if slug == "list_variables" and state.get("pending_mount"): + _emit(additional_context=LIST_VARS_NUDGE) + return + + _emit() + + +def _handle_stop(data: dict) -> None: + state = _load_state(conversation_id(data)) + if state.get("pending_mount"): + _emit(followup_message=_stop_followup(data)) + return + _emit() + + +def main() -> None: + raw = sys.stdin.read() + if not raw.strip(): + _emit() + return + + try: + data = json.loads(raw) + except json.JSONDecodeError: + _emit() + return + + event = data.get("hook_event_name") or "" + _debug(f"{event} tool={tool_name(data)!r} slug={mcp_tool_slug(data)!r}") + + if event == "stop": + _handle_stop(data) + return + + if event in ("afterMCPExecution", "postToolUse"): + _handle_after_mcp(data) + return + + _emit() + + +if __name__ == "__main__": + main() diff --git a/scripts/require-1password-skill-read b/scripts/require-1password-skill-read new file mode 100755 index 0000000..1f9992e --- /dev/null +++ b/scripts/require-1password-skill-read @@ -0,0 +1,76 @@ +#!/usr/bin/env python3 +"""Block 1Password MCP calls until the agent reads the bundled plugin skill.""" + +from __future__ import annotations + +import json +import os +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) + +from lib.hook_state import ( # noqa: E402 + can_enforce_skill_gate, + conversation_id, + is_1password_mcp_call, + is_skill_read, + skill_read_instruction, +) + + +def _debug(message: str) -> None: + if os.environ.get("DEBUG"): + with open("/tmp/1password-cursor-hooks.log", "a", encoding="utf-8") as log: + log.write(f"require-1password-skill-read: {message}\n") + + +def _allow() -> None: + print(json.dumps({"permission": "allow"})) + + +def _deny(data: dict) -> None: + message = skill_read_instruction(data) + print( + json.dumps( + { + "permission": "deny", + "user_message": "Read the 1password-environments plugin skill before using 1Password MCP tools.", + "agent_message": message, + } + ) + ) + sys.exit(2) + + +def main() -> None: + raw = sys.stdin.read() + if not raw.strip(): + _allow() + return + + try: + data = json.loads(raw) + except json.JSONDecodeError: + _allow() + return + + if not is_1password_mcp_call(data): + _allow() + return + + if not can_enforce_skill_gate(data): + _debug("no conversation id; failing open") + _allow() + return + + conv_id = conversation_id(data) + if is_skill_read(conv_id): + _allow() + return + + _deny(data) + + +if __name__ == "__main__": + main() diff --git a/scripts/test_hook_state.py b/scripts/test_hook_state.py new file mode 100644 index 0000000..eab49cf --- /dev/null +++ b/scripts/test_hook_state.py @@ -0,0 +1,124 @@ +#!/usr/bin/env python3 +"""Lightweight regression checks for hook_state tool detection.""" + +from __future__ import annotations + +import json +import subprocess +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parent +sys.path.insert(0, str(ROOT)) + +from lib.hook_state import ( # noqa: E402 + can_enforce_skill_gate, + is_1password_mcp_call, + is_full_skill_read, + is_onepassword_mcp_doc_fetch, + mcp_tool_slug, +) + +SKILL = "/Users/x/.cursor/plugins/local/1password/skills/1password-environments/SKILL.md" + + +def check(label: str, condition: bool) -> None: + if not condition: + raise AssertionError(label) + print(f"ok: {label}") + + +def run_require(payload: dict) -> str: + script = ROOT / "require-1password-skill-read" + result = subprocess.run( + [sys.executable, str(script)], + input=json.dumps(payload), + capture_output=True, + text=True, + ) + return json.loads(result.stdout)["permission"] + + +def main() -> None: + check("MCP:append_variables slug", mcp_tool_slug({"tool_name": "MCP:append_variables"}) == "append_variables") + check( + "CallMcpTool slug", + mcp_tool_slug( + { + "tool_name": "CallMcpTool", + "tool_input": { + "server": "user-1password", + "toolName": "append_variables", + }, + } + ) + == "append_variables", + ) + check( + "non-1password CallMcpTool", + mcp_tool_slug( + { + "tool_name": "CallMcpTool", + "tool_input": {"server": "github", "toolName": "search"}, + } + ) + == "", + ) + check( + "fetch docs uri", + is_onepassword_mcp_doc_fetch( + { + "tool_name": "FetchMcpResource", + "tool_input": {"uri": "1password://docs/getting-started"}, + } + ), + ) + check( + "partial read rejected", + not is_full_skill_read({"tool_input": {"path": SKILL, "limit": 20}}), + ) + check( + "full read accepted", + is_full_skill_read({"tool_input": {"path": SKILL}}), + ) + check( + "no conversation id cannot enforce", + not can_enforce_skill_gate({"tool_name": "append_variables"}), + ) + + base = { + "conversation_id": "hook-state-test", + "workspace_roots": [str(ROOT.parent)], + } + check( + "gate denies bare append_variables", + run_require({**base, "tool_name": "append_variables"}) == "deny", + ) + check( + "gate denies MCP:append_variables", + run_require({**base, "tool_name": "MCP:append_variables"}) == "deny", + ) + check( + "gate denies CallMcpTool append_variables", + run_require( + { + **base, + "tool_name": "CallMcpTool", + "tool_input": { + "server": "user-1password", + "toolName": "append_variables", + }, + } + ) + == "deny", + ) + check( + "gate fails open without conversation id", + run_require({"tool_name": "append_variables"}) == "allow", + ) + + print("all hook_state checks passed") + + +if __name__ == "__main__": + main() From c548302d750793e2a7d69d3f2e4cf0312c622378 Mon Sep 17 00:00:00 2001 From: Scott Lougheed Date: Fri, 10 Jul 2026 08:28:10 -0700 Subject: [PATCH 03/10] Simplify plugin to skill workflow and mount validation hook. Remove MCP skill-read gates, doc-deny hooks, import nudges, and the agent rule. Update SKILL.md, reference.md, and README to document only what the plugin ships. Co-authored-by: Cursor --- .cursor-plugin/plugin.json | 1 - README.md | 31 +- hooks/hooks.json | 39 --- rules/1password-environments.mdc | 25 -- scripts/deny-1password-docs-read | 124 -------- scripts/lib/hook_state.py | 311 --------------------- scripts/mark-1password-skill-read | 79 ------ scripts/nudge-1password-import | 225 --------------- scripts/require-1password-skill-read | 76 ----- scripts/test_hook_state.py | 124 -------- skills/1password-environments/SKILL.md | 62 ++-- skills/1password-environments/reference.md | 113 +++++--- 12 files changed, 108 insertions(+), 1102 deletions(-) delete mode 100644 rules/1password-environments.mdc delete mode 100755 scripts/deny-1password-docs-read delete mode 100644 scripts/lib/hook_state.py delete mode 100755 scripts/mark-1password-skill-read delete mode 100755 scripts/nudge-1password-import delete mode 100755 scripts/require-1password-skill-read delete mode 100644 scripts/test_hook_state.py diff --git a/.cursor-plugin/plugin.json b/.cursor-plugin/plugin.json index 68da9ed..4657d0a 100644 --- a/.cursor-plugin/plugin.json +++ b/.cursor-plugin/plugin.json @@ -24,6 +24,5 @@ "logo": "assets/icon.svg", "hooks": "hooks/hooks.json", "skills": "./skills/", - "rules": "rules/", "mcpServers": "./mcp.json" } diff --git a/README.md b/README.md index b68c060..a97fd80 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ The official [1Password](https://1password.com) plugin for [Cursor](https://cursor.com). It ships three pieces that work together: **hooks** that validate locally mounted `.env` files, an **agent skill** with the complete Developer Environment workflow, and **MCP configuration** for the 1Password desktop app server. Secret values stay in 1Password — the agent sees variable names and mount paths, not secret contents. -Install the **plugin** (not a hand-configured MCP entry alone). The bundled `1password-environments` skill and rule are the authoritative agent workflow; the MCP server's built-in documentation resources cover tool basics only and omit import-and-mount steps. +Install the **plugin** (not a hand-configured MCP entry alone). The bundled `1password-environments` skill is the authoritative agent workflow; the MCP server's built-in documentation resources cover tool basics only and omit import-and-mount steps. For more on 1Password's developer tools, see the [1Password Developer Documentation](https://developer.1password.com). @@ -148,17 +148,9 @@ When not running in debug mode, the hook writes logs to `/tmp/1password-cursor-h ### MCP and agent skill -The plugin connects Cursor to the local 1Password MCP server and bundles the **`1password-environments`** skill and **rule** (`skills/1password-environments/SKILL.md`, `rules/1password-environments.mdc`). Agents MUST read that skill before calling MCP tools. +The plugin connects Cursor to the local 1Password MCP server and bundles the **`1password-environments`** skill (`skills/1password-environments/SKILL.md`). Agents should read that skill before calling MCP tools — it defines the complete workflow for importing a plain `.env` file, appending variables, and mounting at the source path. The MCP server's built-in docs cover tool basics but omit those import-and-mount steps. -**Skill-read gate:** a `beforeMCPExecution` hook (`scripts/require-1password-skill-read`) blocks 1Password MCP calls until the agent has Read the **entire** bundled skill in the current conversation. `scripts/mark-1password-skill-read` records that read from the `beforeReadFile` / `preToolUse` Read events (partial reads with `limit` / `offset` / line ranges do not count). - -**Read-deny gate:** a `beforeReadFile` / `preToolUse` hook (`scripts/deny-1password-docs-read`) denies Read on 1Password MCP descriptors under `mcps/` and `FetchMcpResource` for `1password://docs/*`, redirecting to the skill. It fails open so a hook error never blocks a legitimate read. - -**Fail-open enforcement:** the skill-read and read-deny hooks use `failClosed: false`. If a hook script errors or the payload lacks a conversation id, MCP is allowed rather than deadlocked. This plugin targets **macOS** only. - -A **`postToolUse` / `stop` hook** (`scripts/nudge-1password-import`) injects the remaining import steps when an agent stops after `append_variables` without mounting — the common failure mode for `.env` imports. - -The MCP server exposes `1password://docs/getting-started` and `1password://docs/environments-guide`, but those resources are **not** sufficient for agent workflows — they omit importing a plain `.env` file and mounting at the source path by default. The bundled skill defines the complete workflow, including import, append, and mount. +See `skills/1password-environments/reference.md` for setup, mount conflicts, and shell validation details. #### Example prompts @@ -192,27 +184,20 @@ cursor-plugin/ ├── .cursor-plugin/ │ └── plugin.json # Plugin manifest ├── hooks/ -│ └── hooks.json # Hook event configuration +│ └── hooks.json # beforeShellExecution mount validation ├── skills/ │ └── 1password-environments/ │ ├── SKILL.md # Agent skill for MCP workflows -│ └── reference.md # Mount conflict and troubleshooting -├── rules/ -│ └── 1password-environments.mdc # Agent rule (activates on 1Password MCP work) +│ └── reference.md # Setup, mount conflict, and troubleshooting ├── mcp.json # MCP server configuration ├── assets/ │ ├── logo.svg # Plugin logo │ └── icon.svg ├── scripts/ │ ├── lib/ -│ │ ├── hook_state.py # Shared conversation state for Python hooks -│ │ └── telemetry.sh # Shared opt-in telemetry helpers +│ │ └── telemetry.sh # Opt-in telemetry helpers for the validation hook │ ├── validate-mounted-env-files # Bash hook (macOS / Linux) -│ ├── validate-mounted-env-files.cmd # Windows cmd wrapper returns allow (validation skipped) -│ ├── deny-1password-docs-read # Blocks Read on 1Password MCP descriptors under mcps/ -│ ├── mark-1password-skill-read # Records skill read for MCP gate -│ ├── require-1password-skill-read # Blocks 1Password MCP until skill read -│ └── nudge-1password-import # Nudges agents to finish .env import + mount +│ └── validate-mounted-env-files.cmd # Windows cmd wrapper returns allow (validation skipped) ├── LICENSE └── README.md ``` @@ -229,7 +214,7 @@ Reload Cursor after creating or updating the symlink. ## Telemetry -The plugin emits **opt-in** telemetry so 1Password can understand plugin adoption and the prevalence of common failure modes (missing files, disabled mounts). Two event types are emitted: +The validation hook emits **opt-in** telemetry so 1Password can understand plugin adoption and the prevalence of common failure modes (missing files, disabled mounts). Two event types are emitted: - `agent_hook_execution` — fired once per hook invocation; carries the hook name, plugin version, client (`cursor`), bucketed duration, decision (`allow`/`deny`), reason for deny, validation mode (`default`/`configured`), and a count of mounts checked. - `agent_hook_install` — fired once per `(hook_name, plugin_version)` on the first hook run after installation or upgrade; `install_method` is `plugin_marketplace`. diff --git a/hooks/hooks.json b/hooks/hooks.json index 5d23192..d57cfee 100644 --- a/hooks/hooks.json +++ b/hooks/hooks.json @@ -1,49 +1,10 @@ { "version": 1, "hooks": { - "beforeMCPExecution": [ - { - "command": "./scripts/require-1password-skill-read", - "matcher": "1password|CallMcpTool|authenticate|list_environments|create_environment|rename_environment|list_variables|append_variables|create_local_env_file|list_local_env_files", - "failClosed": false - } - ], "beforeShellExecution": [ { "command": "./scripts/validate-mounted-env-files" } - ], - "preToolUse": [ - { - "command": "./scripts/mark-1password-skill-read", - "matcher": "Read File V2|Read" - }, - { - "command": "./scripts/deny-1password-docs-read", - "matcher": "Read File V2|Read|FetchMcpResource", - "failClosed": false - } - ], - "beforeReadFile": [ - { - "command": "./scripts/mark-1password-skill-read" - }, - { - "command": "./scripts/deny-1password-docs-read", - "failClosed": false - } - ], - "postToolUse": [ - { - "command": "./scripts/nudge-1password-import", - "matcher": "CallMcpTool|MCP:.*1password|MCP:append_variables|MCP:create_environment|MCP:create_local_env_file|MCP:list_variables|MCP:authenticate" - } - ], - "stop": [ - { - "command": "./scripts/nudge-1password-import", - "loop_limit": 3 - } ] } } diff --git a/rules/1password-environments.mdc b/rules/1password-environments.mdc deleted file mode 100644 index fa5dfa3..0000000 --- a/rules/1password-environments.mdc +++ /dev/null @@ -1,25 +0,0 @@ ---- -description: >- - 1Password Developer Environments — read skills/1password-environments/SKILL.md - before any 1Password MCP call. Import, mount, or manage repo .env secrets via the - 1password MCP server. Apply on 1Password, Developer Environment, .env import, - migrate, mount, append_variables, create_local_env_file, or project secrets. -alwaysApply: true ---- - -# 1Password Environments (Cursor plugin) - -**Before your first 1Password MCP call in this conversation:** Read -`skills/1password-environments/SKILL.md` in full with the Read tool. Plugin hooks -block MCP until that file is read. Do not read MCP tool descriptors under `mcps/`. -Do not fetch `1password://docs/getting-started` or `environments-guide`. - -`skills/1password-environments/SKILL.md` is the single source of truth for the full -workflow — the step-by-step import sequence, exact MCP tool parameters, the mount -checklist, and mount-conflict handling. Follow it rather than working from memory; -this rule only points you there. - -The one thing worth repeating up front: **an import is not done after -`append_variables`.** You must `create_local_env_file` at the source `.env` path and -verify it with `list_local_env_files`. `list_variables` does not verify a mount, and -mounting is never an optional follow-up. diff --git a/scripts/deny-1password-docs-read b/scripts/deny-1password-docs-read deleted file mode 100755 index e6bf2da..0000000 --- a/scripts/deny-1password-docs-read +++ /dev/null @@ -1,124 +0,0 @@ -#!/usr/bin/env python3 -"""Block Read/Fetch access to 1Password MCP descriptors and docs. - -The 1password-environments skill is the single source of truth for the workflow. -Agents that have not read it tend to spelunk raw MCP tool/resource JSON under -`mcps/**/*1password*/**` or fetch `1password://docs/*`, which wastes turns and -skips the mount checklist. Deny those reads and redirect to the bundled skill. -""" - -from __future__ import annotations - -import json -import os -import re -import sys -from pathlib import Path - -sys.path.insert(0, str(Path(__file__).resolve().parent)) - -from lib.hook_state import ( # noqa: E402 - is_onepassword_mcp_doc_fetch, - is_onepassword_mcp_doc_path, - parse_tool_input, - skill_path_hint, -) - -_READ_TOOL_NAME = re.compile(r"^Read(?: File V2)?$", re.IGNORECASE) -_FETCH_TOOL_NAME = re.compile(r"^FetchMcpResource$", re.IGNORECASE) - - -def _debug(message: str) -> None: - if os.environ.get("DEBUG"): - with open("/tmp/1password-cursor-hooks.log", "a", encoding="utf-8") as log: - log.write(f"deny-1password-docs-read: {message}\n") - - -def _file_path_from_payload(data: dict) -> str: - path = data.get("file_path") - if isinstance(path, str) and path: - return path - - tool_input = parse_tool_input(data) - for key in ("path", "targetFile", "file_path", "filePath"): - value = tool_input.get(key) - if isinstance(value, str) and value: - return value - - return "" - - -def _is_guarded_event(data: dict) -> bool: - if data.get("hook_event_name") == "beforeReadFile": - return True - - tool_name = data.get("tool_name") - if isinstance(tool_name, str): - stripped = tool_name.strip() - if _READ_TOOL_NAME.match(stripped) or _FETCH_TOOL_NAME.match(stripped): - return True - - if data.get("file_path") and not tool_name: - return True - - return False - - -def _should_deny(data: dict) -> bool: - file_path = _file_path_from_payload(data) - if file_path and is_onepassword_mcp_doc_path(file_path): - return True - return is_onepassword_mcp_doc_fetch(data) - - -def _deny(data: dict) -> None: - message = ( - "Do not read 1Password MCP descriptors or docs under `mcps/`. " - f"{skill_path_hint(data)} " - "It is the single source of truth for the workflow and exact tool params. " - "Do not fetch `1password://docs/getting-started` or `environments-guide` either. " - "After reading the skill, call the 1Password MCP tools directly." - ) - print( - json.dumps( - { - "permission": "deny", - "user_message": "Read the 1password-environments plugin skill instead of the raw MCP descriptors.", - "agent_message": message, - } - ) - ) - sys.exit(2) - - -def _allow() -> None: - print(json.dumps({"permission": "allow"})) - - -def main() -> None: - raw = sys.stdin.read() - if not raw.strip(): - _allow() - return - - try: - data = json.loads(raw) - except json.JSONDecodeError: - _allow() - return - - _debug(json.dumps({"event": data.get("hook_event_name"), "tool_name": data.get("tool_name")})) - - if not _is_guarded_event(data): - _allow() - return - - if _should_deny(data): - _debug(f"deny {_file_path_from_payload(data) or 'fetch'}") - _deny(data) - - _allow() - - -if __name__ == "__main__": - main() diff --git a/scripts/lib/hook_state.py b/scripts/lib/hook_state.py deleted file mode 100644 index 619a1eb..0000000 --- a/scripts/lib/hook_state.py +++ /dev/null @@ -1,311 +0,0 @@ -"""Shared conversation state for 1Password Cursor plugin hooks.""" - -from __future__ import annotations - -import json -import re -from pathlib import Path - -STATE_DIR = Path("/tmp/1password-cursor-plugin") -IMPORT_STATE_DIR = STATE_DIR / "import-state" -SKILL_READ_DIR = STATE_DIR / "skill-read" - -_SKILL_MARKERS = ( - "skills/1password-environments/skill.md", - "1password-environments/skill.md", -) - -_ONEPASSWORD_MCP_TOOLS = frozenset( - { - "authenticate", - "list_environments", - "create_environment", - "rename_environment", - "list_variables", - "append_variables", - "create_local_env_file", - "list_local_env_files", - } -) - -_MCP_TOOL_WRAPPERS = frozenset({"callmcptool", "call_mcp_tool"}) -_MCP_FETCH_WRAPPERS = frozenset({"fetchmcpresource", "fetch_mcp_resource"}) - -_ONEPASSWORD_SERVER = re.compile(r"1password", re.IGNORECASE) -_MCP_ONEPASSWORD_PATH = re.compile(r"/mcps/[^/]*1password[^/]*/", re.IGNORECASE) -_ONEPASSWORD_DOC_URI = re.compile( - r"^1password://docs/(?:getting-started|environments-guide)", - re.IGNORECASE, -) - -_CONVERSATION_ID_KEYS = ( - "conversation_id", - "session_id", - "chat_id", - "thread_id", - "generation_id", -) - - -def parse_tool_input(data: dict) -> dict: - raw = data.get("tool_input") - if isinstance(raw, dict): - return raw - if isinstance(raw, str) and raw.strip(): - try: - parsed = json.loads(raw) - if isinstance(parsed, dict): - return parsed - except json.JSONDecodeError: - pass - return {} - - -def conversation_id(data: dict) -> str: - for key in _CONVERSATION_ID_KEYS: - value = data.get(key) - if value is not None and str(value).strip(): - return str(value) - return "" - - -def can_enforce_skill_gate(data: dict) -> bool: - """Return False when no stable conversation key is available. - - Without one, deny would deadlock because mark-1password-skill-read cannot - persist state. Fail open in that case. - """ - return bool(conversation_id(data)) - - -def _safe_id(value: str) -> str: - return re.sub(r"[^A-Za-z0-9._-]", "_", value or "unknown") - - -def skill_read_marker_path(conv_id: str) -> Path: - return SKILL_READ_DIR / f"{_safe_id(conv_id)}.json" - - -def is_skill_read(conv_id: str) -> bool: - if not conv_id: - return False - path = skill_read_marker_path(conv_id) - if not path.is_file(): - return False - try: - payload = json.loads(path.read_text(encoding="utf-8")) - return bool(payload.get("skill_read")) - except (OSError, json.JSONDecodeError): - return False - - -def mark_skill_read(conv_id: str) -> None: - if not conv_id: - return - SKILL_READ_DIR.mkdir(parents=True, exist_ok=True) - skill_read_marker_path(conv_id).write_text( - json.dumps({"skill_read": True}, indent=2), - encoding="utf-8", - ) - - -def skill_paths_for_workspace(workspace_root: str) -> list[str]: - paths: list[str] = [] - if workspace_root: - workspace_skill = ( - Path(workspace_root) / "skills" / "1password-environments" / "SKILL.md" - ) - if workspace_skill.is_file(): - paths.append(str(workspace_skill)) - home = Path.home() - paths.extend( - str(path) - for path in ( - home / ".cursor" / "plugins" / "local" / "1password" / "skills" / "1password-environments" / "SKILL.md", - home / ".cursor" / "plugins" / "cache" / "1password" / "skills" / "1password-environments" / "SKILL.md", - ) - if path.is_file() - ) - cache_root = home / ".cursor" / "plugins" / "cache" - if cache_root.is_dir(): - for skill_path in cache_root.glob("**/skills/1password-environments/SKILL.md"): - resolved = str(skill_path) - if resolved not in paths: - paths.append(resolved) - return paths - - -def skill_path_hint(data: dict) -> str: - """Absolute-path instruction for reading the bundled skill.""" - workspace_roots = data.get("workspace_roots") - workspace_root = workspace_roots[0] if isinstance(workspace_roots, list) and workspace_roots else "" - candidates = skill_paths_for_workspace(workspace_root) - if candidates: - paths = "\n".join(f"- `{path}`" for path in candidates) - return f"Read the ENTIRE file at the first path that exists:\n{paths}" - return ( - "Read the ENTIRE `1password-environments` plugin skill " - "(`skills/1password-environments/SKILL.md` in the plugin root)." - ) - - -def skill_read_instruction(data: dict) -> str: - path_hint = skill_path_hint(data) - - return ( - "1Password MCP calls are blocked until you read the bundled plugin skill. " - f"{path_hint} " - "Do not read MCP tool descriptors under `mcps/`. " - "Do not fetch `1password://docs/getting-started` or `environments-guide`. " - "After reading the skill, retry the MCP call and follow the import checklist " - "(append_variables is not done until create_local_env_file is verified)." - ) - - -def is_skill_markdown_path(path: str) -> bool: - normalized = path.replace("\\", "/").lower() - return any(marker in normalized for marker in _SKILL_MARKERS) - - -def _coerce_int(value: object) -> int | None: - if value is None: - return None - if isinstance(value, bool): - return None - if isinstance(value, int): - return value - if isinstance(value, float): - return int(value) - if isinstance(value, str) and value.strip().lstrip("-").isdigit(): - return int(value.strip()) - return None - - -def is_full_skill_read(data: dict) -> bool: - """Return True only when the Read appears to cover the entire skill file.""" - tool_input = parse_tool_input(data) - - if _coerce_int(tool_input.get("limit")) is not None: - return False - - offset = _coerce_int(tool_input.get("offset")) - if offset is not None and offset > 0: - return False - - for key in ("startLine", "lineStart", "start_line"): - start = _coerce_int(tool_input.get(key)) - if start is not None and start > 1: - return False - - for key in ("endLine", "lineEnd", "end_line"): - if _coerce_int(tool_input.get(key)) is not None: - return False - - return True - - -def is_1password_server(server: str) -> bool: - return bool(server and _ONEPASSWORD_SERVER.search(server)) - - -def normalize_tool_name(name: str) -> str: - lowered = name.strip().lower() - if lowered.startswith("mcp:"): - lowered = lowered[4:] - if "1password" in lowered and "-" in lowered: - return lowered.rsplit("-", 1)[-1] - return lowered - - -def tool_name(data: dict) -> str: - for key in ("tool_name", "toolName"): - value = data.get(key) - if isinstance(value, str) and value: - return value - return "" - - -def mcp_tool_slug(data: dict) -> str: - """Return the 1Password MCP tool slug when this payload is a 1Password MCP call.""" - name = tool_name(data) - if not name: - return "" - - lowered = name.strip().lower() - tool_input = parse_tool_input(data) - - if lowered in _MCP_TOOL_WRAPPERS: - server = str( - tool_input.get("server") - or tool_input.get("mcpServer") - or tool_input.get("mcp_server") - or "" - ) - if not is_1password_server(server): - return "" - slug = str( - tool_input.get("toolName") - or tool_input.get("tool_name") - or tool_input.get("name") - or "" - ).strip() - return normalize_tool_name(slug) if slug else "" - - if "1password" in lowered or "user-1password" in lowered: - slug = normalize_tool_name(name) - return slug if slug in _ONEPASSWORD_MCP_TOOLS else "" - - slug = normalize_tool_name(name) - return slug if slug in _ONEPASSWORD_MCP_TOOLS else "" - - -def mcp_tool_arguments(data: dict) -> dict: - tool_input = parse_tool_input(data) - name = tool_name(data).strip().lower() - - if name in _MCP_TOOL_WRAPPERS: - args = ( - tool_input.get("arguments") - or tool_input.get("args") - or tool_input.get("parameters") - or {} - ) - if isinstance(args, str) and args.strip(): - try: - parsed = json.loads(args) - if isinstance(parsed, dict): - return parsed - except json.JSONDecodeError: - return {} - return args if isinstance(args, dict) else {} - - return tool_input - - -def is_1password_mcp_call(data: dict) -> bool: - slug = mcp_tool_slug(data) - if not slug: - return False - return slug in _ONEPASSWORD_MCP_TOOLS - - -def is_onepassword_mcp_doc_path(path: str) -> bool: - normalized = path.replace("\\", "/") - return bool(_MCP_ONEPASSWORD_PATH.search(normalized)) - - -def is_onepassword_mcp_doc_fetch(data: dict) -> bool: - name = tool_name(data).strip().lower() - if name not in _MCP_FETCH_WRAPPERS: - return False - - tool_input = parse_tool_input(data) - uri = str(tool_input.get("uri") or tool_input.get("url") or "").strip() - if not uri: - return False - - if _ONEPASSWORD_DOC_URI.match(uri): - return True - - normalized = uri.replace("\\", "/") - return bool(_MCP_ONEPASSWORD_PATH.search(normalized)) diff --git a/scripts/mark-1password-skill-read b/scripts/mark-1password-skill-read deleted file mode 100755 index daf9958..0000000 --- a/scripts/mark-1password-skill-read +++ /dev/null @@ -1,79 +0,0 @@ -#!/usr/bin/env python3 -"""Record that the agent read the 1password-environments plugin skill.""" - -from __future__ import annotations - -import json -import os -import sys -from pathlib import Path - -sys.path.insert(0, str(Path(__file__).resolve().parent)) - -from lib.hook_state import ( # noqa: E402 - can_enforce_skill_gate, - conversation_id, - is_full_skill_read, - is_skill_markdown_path, - mark_skill_read, - parse_tool_input, -) - - -def _debug(message: str) -> None: - if os.environ.get("DEBUG"): - with open("/tmp/1password-cursor-hooks.log", "a", encoding="utf-8") as log: - log.write(f"mark-1password-skill-read: {message}\n") - - -def _file_path_from_payload(data: dict) -> str: - path = data.get("file_path") - if isinstance(path, str) and path: - return path - - tool_input = parse_tool_input(data) - for key in ("path", "targetFile", "file_path", "filePath"): - value = tool_input.get(key) - if isinstance(value, str) and value: - return value - - return "" - - -def _allow() -> None: - print(json.dumps({"permission": "allow"})) - - -def main() -> None: - raw = sys.stdin.read() - if not raw.strip(): - _allow() - return - - try: - data = json.loads(raw) - except json.JSONDecodeError: - _allow() - return - - file_path = _file_path_from_payload(data) - if file_path and is_skill_markdown_path(file_path): - if not is_full_skill_read(data): - _debug(f"ignored partial read of {file_path}") - _allow() - return - - if not can_enforce_skill_gate(data): - _debug(f"no conversation id; ignored read of {file_path}") - _allow() - return - - conv_id = conversation_id(data) - _debug(f"marked skill read for {conv_id!r} via {file_path}") - mark_skill_read(conv_id) - - _allow() - - -if __name__ == "__main__": - main() diff --git a/scripts/nudge-1password-import b/scripts/nudge-1password-import deleted file mode 100755 index fb76524..0000000 --- a/scripts/nudge-1password-import +++ /dev/null @@ -1,225 +0,0 @@ -#!/usr/bin/env python3 -"""Track 1Password import progress and nudge the agent to finish mounting. - -After append_variables during an import, many agents stop early. This hook -injects additional_context (after MCP) and, if the agent still tries to finish, -a stop-hook followup_message until create_local_env_file clears pending state. -""" - -from __future__ import annotations - -import json -import os -import re -import sys -from pathlib import Path - -sys.path.insert(0, str(Path(__file__).resolve().parent)) - -from lib.hook_state import ( # noqa: E402 - IMPORT_STATE_DIR, - conversation_id, - mcp_tool_arguments, - mcp_tool_slug, - skill_path_hint, - tool_name, -) - -STATE_DIR = IMPORT_STATE_DIR - -APPEND_NUDGE = ( - "1Password import checkpoint: append_variables succeeded but the import is NOT " - "complete. Required next MCP calls in order: (1) list_local_env_files — skip " - "create_local_env_file if the source path is already mounted; (2) " - "create_local_env_file with mountPath set to the source .env absolute path " - "(project root /.env when the user said here or project root); (3) " - "list_local_env_files again to verify. list_variables does NOT verify a mount. " - "Do NOT tell the user the task is done. Do NOT offer mounting as an optional " - "follow-up." -) - -LIST_VARS_NUDGE = ( - "1Password import still incomplete: listing variable names does not create or " - "verify a local mount. If you imported from a .env file, call " - "list_local_env_files then create_local_env_file at the source path, then verify " - "with list_local_env_files before responding to the user." -) - - -def _stop_followup(data: dict) -> str: - return ( - "Continue the 1Password .env import from where you left off. If you have not " - f"already read the plugin skill: {skill_path_hint(data)} " - "Then call list_local_env_files, create_local_env_file at the source .env " - "absolute path for this workspace, and verify with list_local_env_files. Do not " - "reply to the user until the mount is verified or the user explicitly declined " - "mounting." - ) - -def _debug(message: str) -> None: - if os.environ.get("DEBUG"): - STATE_DIR.mkdir(parents=True, exist_ok=True) - with (STATE_DIR / "nudge.log").open("a", encoding="utf-8") as log: - log.write(message + "\n") - - -def _mcp_succeeded(data: dict) -> bool: - # afterMCPExecution payloads expose the result as `result_json`; postToolUse - # payloads expose it as `tool_output`. Check both so the nudge can tell a - # failed call from a successful one regardless of which event fired. - raw = data.get("result_json") - if not isinstance(raw, str) or not raw.strip(): - raw = data.get("tool_output") - if not isinstance(raw, str) or not raw.strip(): - return True - try: - result = json.loads(raw) - except json.JSONDecodeError: - return True - if isinstance(result, dict): - if result.get("error"): - return False - if result.get("isError") is True: - return False - return True - - -def _state_path(conversation_id: str) -> Path: - safe = re.sub(r"[^A-Za-z0-9._-]", "_", conversation_id or "unknown") - return STATE_DIR / f"{safe}.json" - - -def _load_state(conversation_id: str) -> dict: - path = _state_path(conversation_id) - if not path.is_file(): - return {} - try: - data = json.loads(path.read_text(encoding="utf-8")) - return data if isinstance(data, dict) else {} - except (OSError, json.JSONDecodeError): - return {} - - -def _save_state(conversation_id: str, state: dict) -> None: - if not conversation_id: - return - STATE_DIR.mkdir(parents=True, exist_ok=True) - _state_path(conversation_id).write_text(json.dumps(state, indent=2), encoding="utf-8") - - -def _clear_state(conversation_id: str) -> None: - path = _state_path(conversation_id) - try: - path.unlink(missing_ok=True) - except OSError: - pass - - -def _variable_count(tool_input: dict) -> int: - variables = tool_input.get("variables") - return len(variables) if isinstance(variables, list) else 0 - - -def _should_track_mount(state: dict, tool_input: dict) -> bool: - if state.get("created_environment_this_session"): - return True - return _variable_count(tool_input) >= 2 - - -def _emit(additional_context: str | None = None, followup_message: str | None = None) -> None: - payload: dict[str, str] = {} - if additional_context: - payload["additional_context"] = additional_context - if followup_message: - payload["followup_message"] = followup_message - if payload: - print(json.dumps(payload)) - else: - print("{}") - - -def _handle_after_mcp(data: dict) -> None: - conv_id = conversation_id(data) - slug = mcp_tool_slug(data) - if not slug: - _emit() - return - - tool_input = mcp_tool_arguments(data) - - if not _mcp_succeeded(data): - _emit() - return - - state = _load_state(conv_id) - - if slug == "create_environment": - state["created_environment_this_session"] = True - _save_state(conv_id, state) - _emit() - return - - if slug == "create_local_env_file": - _clear_state(conv_id) - _emit() - return - - if slug == "append_variables": - if _should_track_mount(state, tool_input): - state["pending_mount"] = True - workspace_roots = data.get("workspace_roots") - if isinstance(workspace_roots, list) and workspace_roots: - state["workspace_root"] = workspace_roots[0] - for key in ("accountId", "environmentId", "environmentName"): - if key in tool_input: - state[key] = tool_input[key] - _save_state(conv_id, state) - _emit(additional_context=APPEND_NUDGE) - return - _save_state(conv_id, state) - _emit() - return - - if slug == "list_variables" and state.get("pending_mount"): - _emit(additional_context=LIST_VARS_NUDGE) - return - - _emit() - - -def _handle_stop(data: dict) -> None: - state = _load_state(conversation_id(data)) - if state.get("pending_mount"): - _emit(followup_message=_stop_followup(data)) - return - _emit() - - -def main() -> None: - raw = sys.stdin.read() - if not raw.strip(): - _emit() - return - - try: - data = json.loads(raw) - except json.JSONDecodeError: - _emit() - return - - event = data.get("hook_event_name") or "" - _debug(f"{event} tool={tool_name(data)!r} slug={mcp_tool_slug(data)!r}") - - if event == "stop": - _handle_stop(data) - return - - if event in ("afterMCPExecution", "postToolUse"): - _handle_after_mcp(data) - return - - _emit() - - -if __name__ == "__main__": - main() diff --git a/scripts/require-1password-skill-read b/scripts/require-1password-skill-read deleted file mode 100755 index 1f9992e..0000000 --- a/scripts/require-1password-skill-read +++ /dev/null @@ -1,76 +0,0 @@ -#!/usr/bin/env python3 -"""Block 1Password MCP calls until the agent reads the bundled plugin skill.""" - -from __future__ import annotations - -import json -import os -import sys -from pathlib import Path - -sys.path.insert(0, str(Path(__file__).resolve().parent)) - -from lib.hook_state import ( # noqa: E402 - can_enforce_skill_gate, - conversation_id, - is_1password_mcp_call, - is_skill_read, - skill_read_instruction, -) - - -def _debug(message: str) -> None: - if os.environ.get("DEBUG"): - with open("/tmp/1password-cursor-hooks.log", "a", encoding="utf-8") as log: - log.write(f"require-1password-skill-read: {message}\n") - - -def _allow() -> None: - print(json.dumps({"permission": "allow"})) - - -def _deny(data: dict) -> None: - message = skill_read_instruction(data) - print( - json.dumps( - { - "permission": "deny", - "user_message": "Read the 1password-environments plugin skill before using 1Password MCP tools.", - "agent_message": message, - } - ) - ) - sys.exit(2) - - -def main() -> None: - raw = sys.stdin.read() - if not raw.strip(): - _allow() - return - - try: - data = json.loads(raw) - except json.JSONDecodeError: - _allow() - return - - if not is_1password_mcp_call(data): - _allow() - return - - if not can_enforce_skill_gate(data): - _debug("no conversation id; failing open") - _allow() - return - - conv_id = conversation_id(data) - if is_skill_read(conv_id): - _allow() - return - - _deny(data) - - -if __name__ == "__main__": - main() diff --git a/scripts/test_hook_state.py b/scripts/test_hook_state.py deleted file mode 100644 index eab49cf..0000000 --- a/scripts/test_hook_state.py +++ /dev/null @@ -1,124 +0,0 @@ -#!/usr/bin/env python3 -"""Lightweight regression checks for hook_state tool detection.""" - -from __future__ import annotations - -import json -import subprocess -import sys -from pathlib import Path - -ROOT = Path(__file__).resolve().parent -sys.path.insert(0, str(ROOT)) - -from lib.hook_state import ( # noqa: E402 - can_enforce_skill_gate, - is_1password_mcp_call, - is_full_skill_read, - is_onepassword_mcp_doc_fetch, - mcp_tool_slug, -) - -SKILL = "/Users/x/.cursor/plugins/local/1password/skills/1password-environments/SKILL.md" - - -def check(label: str, condition: bool) -> None: - if not condition: - raise AssertionError(label) - print(f"ok: {label}") - - -def run_require(payload: dict) -> str: - script = ROOT / "require-1password-skill-read" - result = subprocess.run( - [sys.executable, str(script)], - input=json.dumps(payload), - capture_output=True, - text=True, - ) - return json.loads(result.stdout)["permission"] - - -def main() -> None: - check("MCP:append_variables slug", mcp_tool_slug({"tool_name": "MCP:append_variables"}) == "append_variables") - check( - "CallMcpTool slug", - mcp_tool_slug( - { - "tool_name": "CallMcpTool", - "tool_input": { - "server": "user-1password", - "toolName": "append_variables", - }, - } - ) - == "append_variables", - ) - check( - "non-1password CallMcpTool", - mcp_tool_slug( - { - "tool_name": "CallMcpTool", - "tool_input": {"server": "github", "toolName": "search"}, - } - ) - == "", - ) - check( - "fetch docs uri", - is_onepassword_mcp_doc_fetch( - { - "tool_name": "FetchMcpResource", - "tool_input": {"uri": "1password://docs/getting-started"}, - } - ), - ) - check( - "partial read rejected", - not is_full_skill_read({"tool_input": {"path": SKILL, "limit": 20}}), - ) - check( - "full read accepted", - is_full_skill_read({"tool_input": {"path": SKILL}}), - ) - check( - "no conversation id cannot enforce", - not can_enforce_skill_gate({"tool_name": "append_variables"}), - ) - - base = { - "conversation_id": "hook-state-test", - "workspace_roots": [str(ROOT.parent)], - } - check( - "gate denies bare append_variables", - run_require({**base, "tool_name": "append_variables"}) == "deny", - ) - check( - "gate denies MCP:append_variables", - run_require({**base, "tool_name": "MCP:append_variables"}) == "deny", - ) - check( - "gate denies CallMcpTool append_variables", - run_require( - { - **base, - "tool_name": "CallMcpTool", - "tool_input": { - "server": "user-1password", - "toolName": "append_variables", - }, - } - ) - == "deny", - ) - check( - "gate fails open without conversation id", - run_require({"tool_name": "append_variables"}) == "allow", - ) - - print("all hook_state checks passed") - - -if __name__ == "__main__": - main() diff --git a/skills/1password-environments/SKILL.md b/skills/1password-environments/SKILL.md index 2badb00..970c502 100644 --- a/skills/1password-environments/SKILL.md +++ b/skills/1password-environments/SKILL.md @@ -1,23 +1,26 @@ --- name: 1password-environments description: >- - MANDATORY — read this ENTIRE file before any 1Password MCP call. Canonical - workflow for 1Password Developer Environments in Cursor. Use when creating, - importing, migrating, or mounting .env files; managing repo secrets; listing - Environment variable names; or calling any 1password MCP tool. Import-from-.env - ALWAYS includes create_local_env_file at the source path unless the user - explicitly says not to mount. MCP calls are blocked by plugin hooks until this - file has been read. + Manage 1Password Developer Environments via the bundled MCP server. Use when + creating, importing, migrating, or mounting .env files; listing Environment + variable names; adding or updating repo secrets; or calling any 1password MCP + tool. Import-from-.env always includes create_local_env_file at the source path + unless the user explicitly says not to mount. --- # 1Password Environments -## Read this first +Workflow for the 1Password MCP tools bundled with this plugin. Read this skill +before calling MCP tools — the MCP server's built-in docs cover tool basics but +omit import-and-mount steps. -If you have not read **every section** of this file in the current conversation, -stop. A plugin hook blocks 1Password MCP tools until you read this skill with -the Read tool. Do not substitute MCP docs, tool descriptor JSON under `mcps/`, -or memory from other sessions. +## Prerequisites + +- 1Password desktop app with the **MCP Server** Labs experiment enabled (`onepassword://settings/labs`) +- macOS or Linux for local `.env` mounts (`create_local_env_file`) +- Plugin installed (registers MCP config, this skill, and mount validation hooks together) + +Setup details: [reference.md](reference.md) ## Not done until @@ -32,7 +35,7 @@ Mounting at the source `.env` path is mandatory, not optional follow-up. The **only** exception is the user explicitly opting out ("without mounting", "do not mount", "skip the mount"). Never ask "want me to mount?" — just mount. -Stopping after `create_environment` + `append_variables` is **incomplete and wrong**. +Stopping after `create_environment` + `append_variables` is **incomplete**. `list_variables` is not mount verification. Do not report success until the mount checklist is done. @@ -40,22 +43,23 @@ checklist is done. ## Do not -- Read files under `mcps/**/tools/*.json` — call MCP tools directly -- Fetch `1password://docs/getting-started` or `environments-guide` — they omit import-and-mount; this skill is the workflow - Skip `create_local_env_file` on import unless the user explicitly opted out - Report success before the import checklist (including mount) is complete - Offer mounting as optional follow-up — it is mandatory on import +- Reveal secret values in chat +- Read a mounted `.env` path — once mounted, the path is a live FIFO (named pipe); use `list_variables` instead ## MCP tools -Request params are **camelCase**; responses may use snake_case. Get `accountId` -from `authenticate`; get `environmentId` from `create_environment` / +Discover tool schemas with `GetMcpTools` before calling. Request params are +**camelCase**; responses may use snake_case. Get `accountId` from +`authenticate`; get `environmentId` from `create_environment` / `list_environments`. Pass **every** required param — omitting one fails with `missing field ''`. | Tool | Required params | Returns / notes | |------|-----------------|-----------------| -| `authenticate` | *(none)* | `accountId`. First call each turn. | +| `authenticate` | *(none)* | `accountId`. First call each session. | | `list_environments` | `accountId` | Environments to resolve by name. | | `create_environment` | `accountId`, `environmentName` | `environmentId`. Import continues below. | | `rename_environment` | `accountId`, `environmentId`, `environmentName` | `environmentName` is the **new** name. | @@ -64,6 +68,9 @@ from `authenticate`; get `environmentId` from `create_environment` / | `list_local_env_files` | `accountId`, `environmentId` | Existing mounts. | | `create_local_env_file` | `accountId`, `environmentId`, `environmentName`, `mountPath` | `mountPath` = absolute source `.env` path (macOS/Linux). | +If MCP calls fail with authentication errors, call `mcp_auth` for the +`plugin-1password-1password` server, then retry. + ## Import from a `.env` file Default path: `{workspace_root}/.env` unless the user names another path. @@ -77,7 +84,8 @@ Default path: `{workspace_root}/.env` unless the user names another path. - `create_local_env_file` with `accountId`, `environmentId`, `environmentName`, `mountPath` (absolute path of the original `.env`) - `list_local_env_files` again to verify -If shell is blocked because 1Password already expects a mount at the path, see [reference.md](reference.md) (mount conflict). +If shell commands are blocked because 1Password expects a mount at the path, see +[reference.md](reference.md) (mount conflict). ## Other flows @@ -94,19 +102,15 @@ If shell is blocked because 1Password already expects a mount at the path, see [ - Never reveal secret values in chat - Store secrets with `concealed: true` - Ask before changing variables unless the request is explicit -- A plain source `.env` is fine to Read. But once an environment is mounted, that path becomes a live FIFO (named pipe) — do not Read a mounted FIFO path (reading it can consume/hang the pipe); use `list_variables` to inspect a mounted environment instead - -## Plugin enforcement -This plugin enforces the workflow with hooks: +## Plugin hook -| Hook | Effect | -|------|--------| -| `beforeMCPExecution` | Blocks 1Password MCP until this skill is Read in full | -| `postToolUse` / `stop` | Nudges mount steps after `append_variables` | -| `beforeReadFile` / `preToolUse` | Blocks Read on 1Password MCP descriptors under `mcps/` and `FetchMcpResource` for `1password://docs/*` — read this skill instead | +The plugin runs `validate-mounted-env-files` on `beforeShellExecution`. It blocks +shell commands when 1Password expects a mount that is missing, disabled, or not a +FIFO (for example a plain `.env` still on disk at the mount path). Reading the +`.env` with the Read tool is unaffected. -Treat hook `additional_context`, `agent_message`, and `followup_message` output as mandatory — not suggestions. +Validation modes and recovery steps: [reference.md](reference.md) ## Troubleshooting diff --git a/skills/1password-environments/reference.md b/skills/1password-environments/reference.md index a725d5f..b8bec3f 100644 --- a/skills/1password-environments/reference.md +++ b/skills/1password-environments/reference.md @@ -1,70 +1,91 @@ # 1Password Environments — reference -## Skill-read gate (plugin hook) +Supplement to [SKILL.md](SKILL.md). Use for setup, mount conflicts, and shell +validation behavior. -`scripts/require-1password-skill-read` runs on `beforeMCPExecution`. It **blocks** -1Password MCP until the agent has Read `skills/1password-environments/SKILL.md` in -the current conversation. `scripts/mark-1password-skill-read` records that read on -`beforeReadFile` / `preToolUse` Read. Partial reads (`limit`, `offset`, or line -ranges) do not satisfy the gate — the agent must read the entire skill file. +## Plugin components -If MCP is denied with a skill-read message, Read the skill file end-to-end, then -retry the MCP call. Do not substitute MCP docs or `mcps/**/tools/*.json`. +This plugin ships: -## Read gate (plugin hook) +| Component | Path | Purpose | +|-----------|------|---------| +| MCP config | `mcp.json` | Connects Cursor to the 1Password desktop MCP server | +| Agent skill | `skills/1password-environments/SKILL.md` | Import, mount, and Environment management workflow | +| Mount validation hook | `hooks/hooks.json` → `scripts/validate-mounted-env-files` | Blocks shell when mounts are missing or misconfigured | -`scripts/deny-1password-docs-read` runs on `beforeReadFile` / `preToolUse` and denies -Read on 1Password MCP descriptors and docs under `mcps/**/*1password*/**`, plus -`FetchMcpResource` for `1password://docs/*`, redirecting to `SKILL.md`. Call the MCP -tools directly per this skill. It fails open (`failClosed: false`) so a hook error -never blocks a legitimate read. +Install the plugin from the Cursor marketplace — do not add the MCP server +manually in user settings without the bundled skill and hooks. -## Import completion nudges (plugin hook) +## Setup -The plugin runs `scripts/nudge-1password-import` after relevant 1Password MCP calls and on agent stop. If you appended variables during an import but have not called `create_local_env_file`, the hook injects the next required steps. Treat that as mandatory — do not reply to the user until the mount is verified. +**Requirements** -## Mount conflict (shell validation hook) +- macOS or Linux with the 1Password desktop app installed (local `.env` mounts are macOS/Linux only; on Windows the validation hook is a no-op) +- **1Password Cursor plugin installed** (marketplace or local symlink) so this skill, hooks, and MCP config load together +- 1Password Labs **MCP Server** experiment enabled in the desktop app (`onepassword://settings/labs`) +- Access to a 1Password account with Developer Environments enabled +- `sqlite3` in `PATH` for mount validation (pre-installed on macOS) -The `beforeShellExecution` hook (`scripts/validate-mounted-env-files`) blocks **all** -shell commands when 1Password expects a mount at a path that is missing, disabled, or -not a FIFO (for example a plain `.env` still on disk at the mount path). Reading the -`.env` with the Read tool is unaffected — only shell commands are gated. +The MCP server binary on macOS: -Validation scope depends on `.1password/environments.toml`: +```text +/Applications/1Password.app/Contents/MacOS/1password-mcp +``` -- No TOML file (or no `mount_paths` field) — default mode: all 1Password mount destinations for this workspace are validated. -- `mount_paths = [".env", ...]` — only listed paths are validated. -- `mount_paths = []` — validation disabled for this repo; all shell commands allowed. +On Linux, see the [1Password MCP server documentation](https://www.1password.dev/environments/mcp-server) for the binary path on your platform. -If shell commands are blocked with a message about missing, invalid, or disabled environment files: +**When things fail** -1. Check whether 1Password already has a destination for the same path (`list_local_env_files`, or the 1Password app Destinations tab). -2. Resolve it by one of: - - Temporarily set `mount_paths = []` in `.1password/environments.toml` to disable mount validation for this repo. - - Fix the mount in 1Password (enable the destination, or remove it until migration finishes). +- Authentication or environment access fails — the 1Password desktop app may need approval, unlocking, or account access +- MCP server unavailable — enable the **1Password Labs MCP Server** experiment via `onepassword://settings/labs`. If the Labs setting is missing, the account may not have the required `ai-local-mcp-server` feature flag +- `create_local_env_file` fails — confirm the user is on macOS or Linux +- Shell commands denied while 1Password expects a mount that is missing or disabled — see **Mount conflict** below -The import itself does not need shell — Read the `.env` directly to get its keys and values. +## Mount validation hook -## Setup and troubleshooting +`scripts/validate-mounted-env-files` runs on `beforeShellExecution`. It blocks +**all** shell commands when 1Password expects a mount at a path that is missing, +disabled, or not a FIFO (for example a plain `.env` still on disk at the mount +path). Reading the `.env` with the Read tool is unaffected — only shell commands +are gated. -**Requirements** +The hook **fails open**: if 1Password is not installed, the database is +unavailable, or `sqlite3` is missing, shell commands proceed normally. -- macOS or Linux with the 1Password desktop app installed (local `.env` mounts are macOS/Linux only; on Windows the validation hook is a no-op). -- **1Password Cursor plugin installed** (marketplace or local symlink) so this skill, hooks, and MCP config load together. -- 1Password Labs **MCP Server** experiment enabled in the desktop app (`onepassword://settings/labs`). -- Access to a 1Password account with Developer Environments enabled. +### Validation modes -The MCP server binary on macOS: +Scope depends on `.1password/environments.toml` at the project root: -```text -/Applications/1Password.app/Contents/MacOS/1password-mcp +| Configuration | Behavior | +|---------------|----------| +| No TOML file (or no `mount_paths` field) | **Default mode** — all 1Password mount destinations for this workspace are validated | +| `mount_paths = [".env", ...]` | **Configured mode** — only listed paths are validated | +| `mount_paths = []` | Validation disabled for this repo; all shell commands allowed | + +Example configured mode: + +```toml +mount_paths = [".env", "billing.env"] ``` -On Linux, see the [1Password MCP server documentation](https://www.1password.dev/environments/mcp-server) for the binary path on your platform. +For each path, the hook checks that the file exists, is a FIFO (named pipe), and +is enabled in 1Password. -**When things fail** +### Mount conflict + +If shell commands are blocked with a message about missing, invalid, or disabled +environment files: + +1. Check whether 1Password already has a destination for the same path (`list_local_env_files`, or the 1Password app Destinations tab) +2. Resolve by one of: + - Temporarily set `mount_paths = []` in `.1password/environments.toml` to disable mount validation for this repo + - Fix the mount in 1Password (enable the destination, or remove it until migration finishes) + +The import itself does not need shell — Read the `.env` directly to get its keys +and values, then use MCP tools per [SKILL.md](SKILL.md). + +### Debugging the hook -- Authentication or environment access fails — the 1Password desktop app may need approval, unlocking, or account access. -- MCP server unavailable — enable the **1Password Labs MCP Server** experiment via `onepassword://settings/labs`. If the Labs setting is missing, the account may not have the required `ai-local-mcp-server` feature flag. -- `create_local_env_file` fails — confirm the user is on macOS or Linux. -- Shell commands denied while 1Password expects a mount that is missing or disabled — see **Mount conflict** above. +- **Cursor Settings → Hooks → Execution Log** — look for `beforeShellExecution` entries tied to `validate-mounted-env-files` +- **Debug run:** `DEBUG=1 echo '{"command": "echo test", "workspace_roots": ["/path/to/project"]}' | ./scripts/validate-mounted-env-files` +- **Log file:** `/tmp/1password-cursor-hooks.log` (when not in debug mode) From f542a291c8fdb628f4cc58f6d555e1312f0b6d7a Mon Sep 17 00:00:00 2001 From: Scott Lougheed Date: Fri, 10 Jul 2026 08:35:39 -0700 Subject: [PATCH 04/10] restoring removed section --- README.md | 9 --------- 1 file changed, 9 deletions(-) diff --git a/README.md b/README.md index a97fd80..77d12cb 100644 --- a/README.md +++ b/README.md @@ -202,15 +202,6 @@ cursor-plugin/ └── README.md ``` -## Local development - -Symlink the repository root for local testing: - -```bash -ln -s /path/to/cursor-plugin ~/.cursor/plugins/local/1password -``` - -Reload Cursor after creating or updating the symlink. ## Telemetry From e86af794cc292e0d1ebaf518e93a023b421ec81a Mon Sep 17 00:00:00 2001 From: Scott Lougheed Date: Fri, 10 Jul 2026 08:37:47 -0700 Subject: [PATCH 05/10] simplifying skill --- skills/1password-environments/SKILL.md | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/skills/1password-environments/SKILL.md b/skills/1password-environments/SKILL.md index 970c502..a24b5ec 100644 --- a/skills/1password-environments/SKILL.md +++ b/skills/1password-environments/SKILL.md @@ -57,16 +57,16 @@ Discover tool schemas with `GetMcpTools` before calling. Request params are `list_environments`. Pass **every** required param — omitting one fails with `missing field ''`. -| Tool | Required params | Returns / notes | -|------|-----------------|-----------------| -| `authenticate` | *(none)* | `accountId`. First call each session. | -| `list_environments` | `accountId` | Environments to resolve by name. | -| `create_environment` | `accountId`, `environmentName` | `environmentId`. Import continues below. | -| `rename_environment` | `accountId`, `environmentId`, `environmentName` | `environmentName` is the **new** name. | -| `list_variables` | `accountId`, `environmentId` | Names only — never values. | -| `append_variables` | `accountId`, `environmentId`, `variables: [{ name, value, concealed }]` | `variables` is an **array**; secrets `concealed: true`. | -| `list_local_env_files` | `accountId`, `environmentId` | Existing mounts. | -| `create_local_env_file` | `accountId`, `environmentId`, `environmentName`, `mountPath` | `mountPath` = absolute source `.env` path (macOS/Linux). | +| Tool | Required params | Returns / notes | +| ----------------------- | ----------------------------------------------------------------------- | -------------------------------------------------------- | +| `authenticate` | _(none)_ | `accountId`. First call each session. | +| `list_environments` | `accountId` | Environments to resolve by name. | +| `create_environment` | `accountId`, `environmentName` | `environmentId`. Import continues below. | +| `rename_environment` | `accountId`, `environmentId`, `environmentName` | `environmentName` is the **new** name. | +| `list_variables` | `accountId`, `environmentId` | Names only — never values. | +| `append_variables` | `accountId`, `environmentId`, `variables: [{ name, value, concealed }]` | `variables` is an **array**; secrets `concealed: true`. | +| `list_local_env_files` | `accountId`, `environmentId` | Existing mounts. | +| `create_local_env_file` | `accountId`, `environmentId`, `environmentName`, `mountPath` | `mountPath` = absolute source `.env` path (macOS/Linux). | If MCP calls fail with authentication errors, call `mcp_auth` for the `plugin-1password-1password` server, then retry. From 4188d5a294f3f86e8af735eab18cc612bea4ece1 Mon Sep 17 00:00:00 2001 From: Scott Lougheed Date: Fri, 10 Jul 2026 08:39:00 -0700 Subject: [PATCH 06/10] reverting to old mcp binary --- README.md | 2 +- mcp.json | 2 +- skills/1password-environments/reference.md | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 77d12cb..152c611 100644 --- a/README.md +++ b/README.md @@ -51,7 +51,7 @@ Enable the **MCP Server** experiment in the 1Password desktop app: open **Settin The MCP server binary on macOS: ```text -/Applications/1Password.app/Contents/MacOS/1password-mcp +/Applications/1Password.app/Contents/MacOS/onepassword-mcp ``` On Linux, see the [1Password MCP server documentation](https://www.1password.dev/environments/mcp-server) for the binary path on your platform. diff --git a/mcp.json b/mcp.json index 90b8b4e..c79ee39 100644 --- a/mcp.json +++ b/mcp.json @@ -1,7 +1,7 @@ { "mcpServers": { "1password": { - "command": "/Applications/1Password.app/Contents/MacOS/1password-mcp", + "command": "/Applications/1Password.app/Contents/MacOS/onepassword-mcp", "args": [] } } diff --git a/skills/1password-environments/reference.md b/skills/1password-environments/reference.md index b8bec3f..1481c73 100644 --- a/skills/1password-environments/reference.md +++ b/skills/1password-environments/reference.md @@ -29,7 +29,7 @@ manually in user settings without the bundled skill and hooks. The MCP server binary on macOS: ```text -/Applications/1Password.app/Contents/MacOS/1password-mcp +/Applications/1Password.app/Contents/MacOS/onepassword-mcp ``` On Linux, see the [1Password MCP server documentation](https://www.1password.dev/environments/mcp-server) for the binary path on your platform. From 7a4b2a29a3b7dc0bd1bd1701e9346304ae730b44 Mon Sep 17 00:00:00 2001 From: Scott Lougheed Date: Fri, 10 Jul 2026 12:08:22 -0700 Subject: [PATCH 07/10] Port test marketplace plugin updates Bring over duplicate environment name handling in the skill, richer plugin manifest description, and simplified hooks.json from mcp-plugin-test-marketplace. Co-authored-by: Cursor --- .cursor-plugin/plugin.json | 4 ++-- hooks/hooks.json | 1 - skills/1password-environments/SKILL.md | 19 ++++++++++++++++++- 3 files changed, 20 insertions(+), 4 deletions(-) diff --git a/.cursor-plugin/plugin.json b/.cursor-plugin/plugin.json index 4657d0a..dffacc4 100644 --- a/.cursor-plugin/plugin.json +++ b/.cursor-plugin/plugin.json @@ -2,7 +2,7 @@ "name": "1password", "displayName": "1Password", "version": "1.2.0", - "description": "1Password plugin for Cursor — hooks, bundled agent skill, and MCP config for Developer Environments and local .env mounts. Secret values stay in 1Password.", + "description": "1Password Developer Environments for Cursor. Before calling MCP tools, read the bundled 1password-environments skill. Import-from-.env requires: authenticate → create/list environment → append_variables → create_local_env_file at the source .env path → verify with list_local_env_files. Requires 1Password desktop app with Labs MCP Server enabled (onepassword://settings/labs). macOS/Linux only for local .env mounts.", "author": { "name": "1Password" }, @@ -22,7 +22,7 @@ "developer-environments" ], "logo": "assets/icon.svg", - "hooks": "hooks/hooks.json", + "hooks": "./hooks/hooks.json", "skills": "./skills/", "mcpServers": "./mcp.json" } diff --git a/hooks/hooks.json b/hooks/hooks.json index d57cfee..63b7f6d 100644 --- a/hooks/hooks.json +++ b/hooks/hooks.json @@ -1,5 +1,4 @@ { - "version": 1, "hooks": { "beforeShellExecution": [ { diff --git a/skills/1password-environments/SKILL.md b/skills/1password-environments/SKILL.md index a24b5ec..3eaec62 100644 --- a/skills/1password-environments/SKILL.md +++ b/skills/1password-environments/SKILL.md @@ -46,6 +46,7 @@ checklist is done. - Skip `create_local_env_file` on import unless the user explicitly opted out - Report success before the import checklist (including mount) is complete - Offer mounting as optional follow-up — it is mandatory on import +- Call `create_environment` when `list_environments` already shows that name — ask the user first (see **Duplicate environment name**) - Reveal secret values in chat - Read a mounted `.env` path — once mounted, the path is a live FIFO (named pipe); use `list_variables` instead @@ -71,13 +72,27 @@ Discover tool schemas with `GetMcpTools` before calling. Request params are If MCP calls fail with authentication errors, call `mcp_auth` for the `plugin-1password-1password` server, then retry. +## Duplicate environment name + +Before **`create_environment`**, call **`list_environments`** and check whether the +target `environmentName` already exists. + +If it does, **stop and ask the user** how they want to proceed. Offer options such as: + +- **Use the existing environment** — skip `create_environment`; use its `environmentId` for later steps (`append_variables`, mount, etc.) +- **Use a different name** — wait for a new name from the user, then `create_environment` +- **Cancel** — do not create or modify anything + +Do not silently choose one of these paths. Do not call `create_environment` with a +name that already exists unless the user has explicitly chosen a different name. + ## Import from a `.env` file Default path: `{workspace_root}/.env` unless the user names another path. 1. **Read** the `.env` file with the Read tool to get its keys and values. Strip optional surrounding quotes from values. Pass values to MCP only — never paste secret values into chat. 2. **`authenticate`** → `accountId` -3. **`create_environment`** (new name) or **`list_environments`** (existing) +3. **`list_environments`** — if the target name already exists, follow **Duplicate environment name** and wait for the user's choice. Otherwise **`create_environment`** (new name) or resolve the existing environment per the user's choice. 4. **`append_variables`** with all variables (secrets `concealed: true`) 5. **Mount** — **always** (skip only if the user explicitly said not to mount): - `list_local_env_files` — skip `create_local_env_file` only if a mount already exists at the source path @@ -89,6 +104,8 @@ If shell commands are blocked because 1Password expects a mount at the path, see ## Other flows +**Create new Environment:** authenticate → `list_environments` → if the name exists, follow **Duplicate environment name** → `create_environment` only when the name is available or the user chose a different name. + **Mount existing Environment:** authenticate → resolve Environment → step 5 above. **Inspect names:** authenticate → resolve → `list_variables` → summarize names only. From 8cde9db950072eda62fa2fe8d5d069564922d683 Mon Sep 17 00:00:00 2001 From: Scott Lougheed Date: Fri, 10 Jul 2026 12:39:07 -0700 Subject: [PATCH 08/10] updating skill description --- skills/1password-environments/SKILL.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/skills/1password-environments/SKILL.md b/skills/1password-environments/SKILL.md index 3eaec62..30ff7ff 100644 --- a/skills/1password-environments/SKILL.md +++ b/skills/1password-environments/SKILL.md @@ -2,10 +2,10 @@ name: 1password-environments description: >- Manage 1Password Developer Environments via the bundled MCP server. Use when - creating, importing, migrating, or mounting .env files; listing Environment - variable names; adding or updating repo secrets; or calling any 1password MCP - tool. Import-from-.env always includes create_local_env_file at the source path - unless the user explicitly says not to mount. + creating, importing, or mounting .env files; listing Environment variable names; + adding or updating Environment variables; renaming environments; or calling any + 1Password MCP tool. Import-from-.env always includes create_local_env_file at + the source .env path unless the user explicitly opts out of mounting. --- # 1Password Environments From 36a788ebd096d4a81f2bba3021a7b3e2aba189e4 Mon Sep 17 00:00:00 2001 From: Scott Lougheed Date: Fri, 10 Jul 2026 12:41:27 -0700 Subject: [PATCH 09/10] updating plugin description --- .cursor-plugin/plugin.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.cursor-plugin/plugin.json b/.cursor-plugin/plugin.json index dffacc4..881857a 100644 --- a/.cursor-plugin/plugin.json +++ b/.cursor-plugin/plugin.json @@ -2,7 +2,7 @@ "name": "1password", "displayName": "1Password", "version": "1.2.0", - "description": "1Password Developer Environments for Cursor. Before calling MCP tools, read the bundled 1password-environments skill. Import-from-.env requires: authenticate → create/list environment → append_variables → create_local_env_file at the source .env path → verify with list_local_env_files. Requires 1Password desktop app with Labs MCP Server enabled (onepassword://settings/labs). macOS/Linux only for local .env mounts.", + "description": "1Password Developer Environments for Cursor: MCP tools to create, import, and manage project secrets; an agent skill with the full import-and-mount workflow; and a hook that validates local .env mounts before shell commands run. Requires 1Password desktop with Labs MCP Server enabled. Local .env mounts: macOS/Linux only.", "author": { "name": "1Password" }, From aae359ba5c79d7662ae743faba03893811eebee3 Mon Sep 17 00:00:00 2001 From: Scott Lougheed Date: Fri, 10 Jul 2026 13:33:54 -0700 Subject: [PATCH 10/10] returning version --- hooks/hooks.json | 1 + 1 file changed, 1 insertion(+) diff --git a/hooks/hooks.json b/hooks/hooks.json index 63b7f6d..d57cfee 100644 --- a/hooks/hooks.json +++ b/hooks/hooks.json @@ -1,4 +1,5 @@ { + "version": 1, "hooks": { "beforeShellExecution": [ {