@pushary/agent-hooks 0.11.1 → 0.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/data/cursor-plugin/.cursor-plugin/plugin.json +25 -0
- package/data/cursor-plugin/CHANGELOG.md +10 -0
- package/data/cursor-plugin/LICENSE +21 -0
- package/data/cursor-plugin/README.md +111 -0
- package/data/cursor-plugin/SECURITY.md +33 -0
- package/data/cursor-plugin/assets/logo.png +0 -0
- package/data/cursor-plugin/commands/notify-when-done.md +14 -0
- package/data/cursor-plugin/commands/pushary-test.md +13 -0
- package/data/cursor-plugin/hooks/hooks.json +13 -0
- package/data/cursor-plugin/mcp.json +11 -0
- package/data/cursor-plugin/rules/pushary.mdc +40 -0
- package/data/cursor-plugin/scripts/pushary-gate.mjs +349 -0
- package/data/cursor-plugin/skills/pushary/SKILL.md +297 -0
- package/dist/bin/pushary-clean.js +8 -1
- package/dist/bin/pushary-codex.js +3 -2
- package/dist/bin/pushary-doctor.js +5 -3
- package/dist/bin/pushary-hook.js +3 -3
- package/dist/bin/pushary-post-hook.js +3 -2
- package/dist/bin/pushary-prompt-hook.d.ts +1 -0
- package/dist/bin/pushary-prompt-hook.js +25 -0
- package/dist/bin/pushary-setup.js +40 -31
- package/dist/bin/pushary-stop-hook.js +3 -2
- package/dist/chunk-22CV7V7A.js +38 -0
- package/dist/chunk-5MA3CPZB.js +141 -0
- package/dist/chunk-CH53PBQN.js +265 -0
- package/dist/chunk-RNWPCELY.js +176 -0
- package/dist/chunk-WCGKLHCL.js +154 -0
- package/dist/src/index.d.ts +10 -2
- package/dist/src/index.js +9 -9
- package/package.json +4 -3
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "pushary",
|
|
3
|
+
"displayName": "Pushary — Control Panel for AI Agents",
|
|
4
|
+
"description": "Push notifications, human-in-the-loop questions, and permission gating for your AI coding agent. Get a push when a task finishes, answer the agent from your phone, and approve risky commands before they run.",
|
|
5
|
+
"version": "0.1.0",
|
|
6
|
+
"author": { "name": "Pushary", "email": "business@pushary.com" },
|
|
7
|
+
"homepage": "https://pushary.com",
|
|
8
|
+
"repository": "https://github.com/Pushary/cursor-plugin",
|
|
9
|
+
"license": "MIT",
|
|
10
|
+
"keywords": [
|
|
11
|
+
"notifications",
|
|
12
|
+
"push",
|
|
13
|
+
"human-in-the-loop",
|
|
14
|
+
"permissions",
|
|
15
|
+
"approvals",
|
|
16
|
+
"mcp",
|
|
17
|
+
"agent",
|
|
18
|
+
"control-panel",
|
|
19
|
+
"ask",
|
|
20
|
+
"alerts"
|
|
21
|
+
],
|
|
22
|
+
"category": "developer-tools",
|
|
23
|
+
"tags": ["notifications", "approvals", "human-in-the-loop", "mcp", "workflow"],
|
|
24
|
+
"logo": "assets/logo.png"
|
|
25
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 0.1.0
|
|
4
|
+
|
|
5
|
+
Initial release.
|
|
6
|
+
|
|
7
|
+
- MCP server (`send_notification`, `ask_user`, `wait_for_answer`, `cancel_question`) wired via `mcp.json`, key supplied at runtime through `PUSHARY_API_KEY`.
|
|
8
|
+
- Always-on rule and full tool-reference skill so the agent uses Pushary proactively.
|
|
9
|
+
- `beforeShellExecution` gate (`scripts/pushary-gate.mjs`, zero-dependency) that evaluates risky commands against your Pushary dashboard policy — auto-approve, the four approval modes, timeout actions, live mode override, and kill switch, scoped to the Cursor conversation. Falls back to Cursor's own prompt when Pushary is unreachable, and is fail-closed: a broken gate blocks rather than allows.
|
|
10
|
+
- Commands: `/pushary-test`, `/notify-when-done`.
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Pushary
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
<p align="center">
|
|
2
|
+
<img src="assets/logo.png" alt="Pushary" width="72" height="72" />
|
|
3
|
+
</p>
|
|
4
|
+
|
|
5
|
+
<h1 align="center">Pushary for Cursor</h1>
|
|
6
|
+
|
|
7
|
+
<p align="center">A control panel for your AI coding agent. Get a push when work finishes, answer the agent from your phone, and approve risky commands before they run.</p>
|
|
8
|
+
|
|
9
|
+
---
|
|
10
|
+
|
|
11
|
+
## What it is
|
|
12
|
+
|
|
13
|
+
Pushary connects Cursor to your phone. When the agent finishes a task, needs a decision, or is about to run something risky, it reaches you with a push notification. You answer from the lock screen and the agent keeps going. It works even when you have stepped away from your computer.
|
|
14
|
+
|
|
15
|
+
## What it does
|
|
16
|
+
|
|
17
|
+
There are three things.
|
|
18
|
+
|
|
19
|
+
1. Notify. The agent sends a push when a long task finishes, or when a build, test, or deploy fails. The push can include what changed, the error, and suggested next steps.
|
|
20
|
+
|
|
21
|
+
2. Ask. The agent asks you questions through push: yes or no, multiple choice, or free text. It waits for your answer. When Pushary is connected, the agent sends its questions to your phone instead of waiting in the editor.
|
|
22
|
+
|
|
23
|
+
3. Gate. Risky shell commands (like rm, force push, history rewrites, database drops, deploys, and systemctl) are checked before they run. What happens is set by your Pushary dashboard policy: auto approve trusted commands, push to your phone for approval, or just notify. If you do not answer in time, it falls back to Cursor's own prompt, so nothing dangerous runs silently. If the check cannot run at all, the command is blocked instead of allowed.
|
|
24
|
+
|
|
25
|
+
## Install
|
|
26
|
+
|
|
27
|
+
You need two things: the plugin, and an API key.
|
|
28
|
+
|
|
29
|
+
### Option 1: Cursor Marketplace (recommended)
|
|
30
|
+
|
|
31
|
+
Open the Marketplace panel in Cursor, search for Pushary, and click install. Then set your API key (see below).
|
|
32
|
+
|
|
33
|
+
### Option 2: CLI
|
|
34
|
+
|
|
35
|
+
This also sets up Claude Code, Codex, and Hermes if you use them.
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
npx @pushary/agent-hooks@latest setup
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
### Option 3: Manual MCP
|
|
42
|
+
|
|
43
|
+
Add this to `.cursor/mcp.json` in your project, or `~/.cursor/mcp.json` for every project:
|
|
44
|
+
|
|
45
|
+
```json
|
|
46
|
+
{
|
|
47
|
+
"mcpServers": {
|
|
48
|
+
"pushary": {
|
|
49
|
+
"type": "http",
|
|
50
|
+
"url": "https://pushary.com/api/mcp/mcp",
|
|
51
|
+
"headers": { "Authorization": "Bearer ${PUSHARY_API_KEY}" }
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
However you install it, Pushary talks to the same server, so the plugin and the CLI give you the same setup.
|
|
58
|
+
|
|
59
|
+
## Set your API key
|
|
60
|
+
|
|
61
|
+
The plugin reads your key from the `PUSHARY_API_KEY` environment variable. Get a key at https://pushary.com, then add it to your shell profile:
|
|
62
|
+
|
|
63
|
+
```bash
|
|
64
|
+
echo 'export PUSHARY_API_KEY="pk_xxx.sk_xxx"' >> ~/.zshrc
|
|
65
|
+
source ~/.zshrc
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
Install the Pushary app on your phone (or turn on web push) so the agent can reach you.
|
|
69
|
+
|
|
70
|
+
## What is in the plugin
|
|
71
|
+
|
|
72
|
+
| Part | File | What it does |
|
|
73
|
+
|------|------|--------------|
|
|
74
|
+
| MCP server | `mcp.json` | Connects Cursor to the Pushary tools: `send_notification`, `ask_user`, `wait_for_answer`, `cancel_question` |
|
|
75
|
+
| Rule | `rules/pushary.mdc` | Always on guidance so the agent uses Pushary on its own |
|
|
76
|
+
| Skill | `skills/pushary/SKILL.md` | Full tool reference: parameters, examples, return values |
|
|
77
|
+
| Hook | `hooks/hooks.json` and `scripts/pushary-gate.mjs` | Sends risky commands to your phone for approval |
|
|
78
|
+
| Commands | `commands/` | `/pushary-test` and `/notify-when-done` |
|
|
79
|
+
|
|
80
|
+
## How the gate decides
|
|
81
|
+
|
|
82
|
+
There are two layers.
|
|
83
|
+
|
|
84
|
+
1. The matcher in `hooks/hooks.json` is a list of patterns that decides which commands get checked at all. Edit it to add or remove patterns.
|
|
85
|
+
|
|
86
|
+
2. Your Pushary dashboard policy decides what happens to a checked command, per tool: auto approve, the approval mode (push and wait, push then prompt, notify only, or prompt only), the timeout action, a live mode override, and the kill switch. This is the same policy your other Pushary agents use, so the behavior stays the same across agents.
|
|
87
|
+
|
|
88
|
+
## Commands
|
|
89
|
+
|
|
90
|
+
- `/pushary-test` sends a test push so you can confirm delivery.
|
|
91
|
+
- `/notify-when-done` tells the agent to push a summary when the current task finishes.
|
|
92
|
+
|
|
93
|
+
## Development
|
|
94
|
+
|
|
95
|
+
`skills/pushary/SKILL.md` mirrors the Pushary skill that ships with `@pushary/agent-hooks`. Keep the two the same.
|
|
96
|
+
|
|
97
|
+
Test the plugin locally before publishing:
|
|
98
|
+
|
|
99
|
+
```bash
|
|
100
|
+
ln -s "$(pwd)" ~/.cursor/plugins/local/pushary
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
Then reload Cursor with Developer: Reload Window.
|
|
104
|
+
|
|
105
|
+
## Security
|
|
106
|
+
|
|
107
|
+
This repository has no secrets. Your key is read at runtime from `PUSHARY_API_KEY`. The gate script has no dependencies and only talks to pushary.com. Read `scripts/pushary-gate.mjs` to see exactly what it sends. See `SECURITY.md` for details.
|
|
108
|
+
|
|
109
|
+
## License
|
|
110
|
+
|
|
111
|
+
MIT. See `LICENSE`.
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# Security
|
|
2
|
+
|
|
3
|
+
## Reporting a vulnerability
|
|
4
|
+
|
|
5
|
+
Email **security@pushary.com** with details and reproduction steps. Please do not open a public
|
|
6
|
+
issue for security reports. We aim to acknowledge within 72 hours.
|
|
7
|
+
|
|
8
|
+
## What this plugin sends
|
|
9
|
+
|
|
10
|
+
The plugin connects Cursor to the Pushary MCP server at `https://pushary.com/api/mcp/mcp` using
|
|
11
|
+
the API key you provide via the `PUSHARY_API_KEY` environment variable. No key is committed to
|
|
12
|
+
this repository.
|
|
13
|
+
|
|
14
|
+
The permission gate (`scripts/pushary-gate.mjs`) runs only on shell commands matching the regex
|
|
15
|
+
in `hooks/hooks.json`. For a matched command it sends, over HTTPS to Pushary:
|
|
16
|
+
|
|
17
|
+
- the command text,
|
|
18
|
+
- the basename of the working directory (e.g. `my-repo`, not the full path),
|
|
19
|
+
- an agent label (`Cursor - <project>`),
|
|
20
|
+
- the Cursor conversation id, so the dashboard kill switch and per-session mode can target this session,
|
|
21
|
+
- a machine id — the first 8 hex characters of a SHA-256 of your hostname (never the hostname itself).
|
|
22
|
+
|
|
23
|
+
It also fetches your permission policy and mode from `pushary.com` (the policy is cached in the
|
|
24
|
+
system temp directory for 5 minutes). It contacts no host other than `pushary.com`, has no
|
|
25
|
+
third-party dependencies, and writes only that policy cache to disk. The full source is in this
|
|
26
|
+
repository — read it before installing.
|
|
27
|
+
|
|
28
|
+
## Fail-closed by design
|
|
29
|
+
|
|
30
|
+
The gate is configured `failClosed: true`. If the gate cannot produce a decision (crash,
|
|
31
|
+
timeout, invalid output), Cursor **blocks** the matched command rather than letting it run
|
|
32
|
+
unapproved. When Pushary is reachable but you don't answer in time, it falls back to Cursor's
|
|
33
|
+
own in-editor approval prompt.
|
|
Binary file
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: notify-when-done
|
|
3
|
+
description: Send a Pushary push summarizing what changed when the current task finishes.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
When you finish the current task, send a Pushary push notification so the user knows it's done — even if they have stepped away from the editor.
|
|
7
|
+
|
|
8
|
+
Call `send_notification` with:
|
|
9
|
+
- `title`: a short summary (under 60 chars) of what was completed
|
|
10
|
+
- `body`: one line on the outcome (under 200 chars)
|
|
11
|
+
- `agentName`: `"Cursor - {project}"`
|
|
12
|
+
- `context`: `{ type: "task_complete", summary, filesChanged, nextSteps }`
|
|
13
|
+
|
|
14
|
+
Send a single notification for the task. If the task fails instead, send `context.type: "error"` with `errorMessage` and the file where it failed.
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: pushary-test
|
|
3
|
+
description: Send a test Pushary push notification to confirm notifications are working.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
Send a test push notification with the Pushary `send_notification` tool so the user can confirm delivery on their phone.
|
|
7
|
+
|
|
8
|
+
Call `send_notification` with:
|
|
9
|
+
- `title`: "Pushary test"
|
|
10
|
+
- `body`: "If you can read this on your phone, Pushary is working."
|
|
11
|
+
- `agentName`: `"Cursor - {project}"` (use the current project folder name)
|
|
12
|
+
|
|
13
|
+
Then tell the user it was sent and to check their device. If the call fails, report the error and remind them to set the `PUSHARY_API_KEY` environment variable and sign in at https://pushary.com.
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 1,
|
|
3
|
+
"hooks": {
|
|
4
|
+
"beforeShellExecution": [
|
|
5
|
+
{
|
|
6
|
+
"command": "node ./scripts/pushary-gate.mjs",
|
|
7
|
+
"matcher": "\\brm\\b|\\brmdir\\b|\\bunlink\\b|\\bmkfs|\\bdd\\b|\\bshutdown\\b|\\breboot\\b|\\bpkill\\b|\\bkillall\\b|\\bsystemctl\\b|--force\\b|force-push|reset --hard|\\brebase\\b|\\bdrop\\b|\\bDROP\\b|\\btruncate\\b|\\bTRUNCATE\\b|delete from|DELETE FROM|\\bdeploy\\b|\\bpublish\\b|\\brelease\\b|\\bmigrate\\b",
|
|
8
|
+
"timeout": 60,
|
|
9
|
+
"failClosed": true
|
|
10
|
+
}
|
|
11
|
+
]
|
|
12
|
+
}
|
|
13
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: Pushary is connected. Use it to ask the user questions, notify them, and gate risky commands through push.
|
|
3
|
+
alwaysApply: true
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Pushary
|
|
7
|
+
|
|
8
|
+
Pushary is connected as an MCP server. It reaches the user on their phone, so use it on your own without being asked. Assume the user may be away from the editor.
|
|
9
|
+
|
|
10
|
+
## Ask the user through Pushary
|
|
11
|
+
|
|
12
|
+
When Pushary is connected, it is your channel for reaching the user. Any time you would stop and ask the user something, ask it through the `ask_user` tool and wait for the answer, instead of only asking in the chat. This covers all of your questions:
|
|
13
|
+
|
|
14
|
+
- Yes or no. Use type "confirm".
|
|
15
|
+
- Multiple choice. Use type "select" with 2 to 6 options.
|
|
16
|
+
- A name, a path, a value, or other free text. Use type "input".
|
|
17
|
+
- Approval of a plan or an approach before you start. Use type "select" with the options the user is choosing between, or "confirm" to approve a single plan.
|
|
18
|
+
|
|
19
|
+
`ask_user` blocks and returns `{ answered, value }`. Always set `context` to one line on what you are doing, and set `agentName` to "Cursor - {project}". If `answered` is false, you did not get an answer in time: pick the safe default or stop and tell the user. Never assume approval.
|
|
20
|
+
|
|
21
|
+
If the user answers in the chat before the push comes back, continue and call `cancel_question` with the correlationId to clean up.
|
|
22
|
+
|
|
23
|
+
## Notify the user
|
|
24
|
+
|
|
25
|
+
Use `send_notification` when something is worth the user's attention:
|
|
26
|
+
|
|
27
|
+
- A task of 3 or more steps finishes. Use `context.type` "task_complete" with a short summary and the files changed.
|
|
28
|
+
- A build, test, or deploy fails. Use `context.type` "error" with the error message.
|
|
29
|
+
- A long job finishes, such as a migration, refactor, or generation.
|
|
30
|
+
|
|
31
|
+
## Risky commands are gated for you
|
|
32
|
+
|
|
33
|
+
Risky shell commands (rm, force push, history rewrites, database drops, deploys, systemctl, and similar) are sent to the user for phone approval automatically by the plugin's hook, following the user's Pushary policy. You do not need to ask separately for those. For anything else destructive that the hook might not catch, ask first with `ask_user`.
|
|
34
|
+
|
|
35
|
+
## Keep it tidy
|
|
36
|
+
|
|
37
|
+
- Keep titles under 60 characters and bodies under 200. Put detail in the `context` object, not the title.
|
|
38
|
+
- Send at most 3 notifications per task unless the user asks for more.
|
|
39
|
+
|
|
40
|
+
For the full tool reference, with every parameter, examples, and return values, see the pushary skill.
|
|
@@ -0,0 +1,349 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Pushary gate — Cursor `beforeShellExecution` hook.
|
|
3
|
+
//
|
|
4
|
+
// Routes risky shell commands through your Pushary permission policy before they
|
|
5
|
+
// run. Which commands reach this gate is the `matcher` in ../hooks/hooks.json; what
|
|
6
|
+
// HAPPENS to a matched command is decided by your dashboard policy (the same policy
|
|
7
|
+
// the @pushary/agent-hooks CLI uses for Claude Code), so behavior is consistent
|
|
8
|
+
// across agents.
|
|
9
|
+
//
|
|
10
|
+
// It honors, per tool ("Bash"): auto-approve, the four approval modes
|
|
11
|
+
// (push_only / push_first / notify_only / terminal_only), the timeout action
|
|
12
|
+
// (approve / deny / escalate), a live mode override, and the kill switch — all
|
|
13
|
+
// scoped to the Cursor conversation. Policy is cached in the temp dir for 5 minutes
|
|
14
|
+
// with a stale-fallback, and requests retry.
|
|
15
|
+
//
|
|
16
|
+
// Self-contained: no dependencies, uses the global fetch (Node 18+).
|
|
17
|
+
//
|
|
18
|
+
// Contract (https://cursor.com/docs/hooks):
|
|
19
|
+
// stdin : { "command": string, "cwd": string, "conversation_id": string, ... }
|
|
20
|
+
// stdout : { "permission": "allow" | "deny" | "ask", "user_message"?, "agent_message"? }
|
|
21
|
+
//
|
|
22
|
+
// Failure model: every handled path writes a decision and exits 0. Network/parse
|
|
23
|
+
// errors and no-policy fall back to "ask" (Cursor's own prompt) — it never silently
|
|
24
|
+
// allows a risky command. A 55s hard guard guarantees a decision before the hook's
|
|
25
|
+
// `failClosed` deadline; only a catastrophic crash (e.g. Node missing) leaves no
|
|
26
|
+
// output, in which case `failClosed: true` blocks the command rather than allowing
|
|
27
|
+
// it unapproved.
|
|
28
|
+
|
|
29
|
+
import { createHash } from 'node:crypto'
|
|
30
|
+
import { hostname, tmpdir } from 'node:os'
|
|
31
|
+
import { existsSync, readFileSync, writeFileSync } from 'node:fs'
|
|
32
|
+
import { basename, dirname, join } from 'node:path'
|
|
33
|
+
import { fileURLToPath } from 'node:url'
|
|
34
|
+
|
|
35
|
+
const BASE_URL = 'https://pushary.com'
|
|
36
|
+
const MCP_URL = `${BASE_URL}/api/mcp/mcp`
|
|
37
|
+
const POLICY_CACHE_TTL_MS = 5 * 60 * 1000
|
|
38
|
+
const MAX_BLOCK_MS = 45_000 // longest we can wait before Cursor's hook timeout
|
|
39
|
+
const WAIT_CHUNK_MS = 20_000 // per wait_for_answer long-poll
|
|
40
|
+
const POLL_GAP_MS = 1_500 // pause between polls after a transient error
|
|
41
|
+
const NET_TIMEOUT_MS = 27_000 // abort a single MCP request
|
|
42
|
+
const POLICY_TIMEOUT_MS = 10_000
|
|
43
|
+
const MODE_TIMEOUT_MS = 3_000
|
|
44
|
+
const HARD_GUARD_MS = 55_000 // force a graceful "ask" before failClosed (60s) fires
|
|
45
|
+
|
|
46
|
+
// ── Cursor decisions ──────────────────────────────────────────────────────────
|
|
47
|
+
const ALLOW = { permission: 'allow' }
|
|
48
|
+
const ask = (agentMessage) => (agentMessage ? { permission: 'ask', agent_message: agentMessage } : { permission: 'ask' })
|
|
49
|
+
const deny = (agentMessage) => ({ permission: 'deny', user_message: 'Command denied via Pushary.', agent_message: agentMessage })
|
|
50
|
+
|
|
51
|
+
let done = false
|
|
52
|
+
const respond = (decision) => {
|
|
53
|
+
if (done) return
|
|
54
|
+
done = true
|
|
55
|
+
process.stdout.write(JSON.stringify(decision))
|
|
56
|
+
process.exit(0)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Backstop: if anything hangs, return "ask" rather than letting the hook time out
|
|
60
|
+
// (which, with failClosed, would block the command).
|
|
61
|
+
setTimeout(() => respond(ask()), HARD_GUARD_MS).unref()
|
|
62
|
+
|
|
63
|
+
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
|
|
64
|
+
const clamp = (n, lo, hi) => Math.min(Math.max(n, lo), hi)
|
|
65
|
+
|
|
66
|
+
const withRetry = async (fn, attempts) => {
|
|
67
|
+
let lastError
|
|
68
|
+
for (let i = 0; i < attempts; i += 1) {
|
|
69
|
+
try {
|
|
70
|
+
return await fn()
|
|
71
|
+
} catch (error) {
|
|
72
|
+
lastError = error
|
|
73
|
+
if (i < attempts - 1) await sleep(300 * (i + 1))
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
throw lastError
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const readStdin = async () => {
|
|
80
|
+
let raw = ''
|
|
81
|
+
for await (const chunk of process.stdin) raw += chunk
|
|
82
|
+
return raw
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const getMachineId = () => createHash('sha256').update(hostname()).digest('hex').slice(0, 8)
|
|
86
|
+
|
|
87
|
+
// Env first. CLI installs inject the key into the sibling mcp.json, so fall back to
|
|
88
|
+
// that. This lets the gate work even when Cursor's GUI does not pass the shell env.
|
|
89
|
+
const resolveApiKey = () => {
|
|
90
|
+
const fromEnv = process.env.PUSHARY_API_KEY
|
|
91
|
+
if (fromEnv) return fromEnv
|
|
92
|
+
try {
|
|
93
|
+
const mcpPath = join(dirname(fileURLToPath(import.meta.url)), '..', 'mcp.json')
|
|
94
|
+
const auth = JSON.parse(readFileSync(mcpPath, 'utf-8'))?.mcpServers?.pushary?.headers?.Authorization ?? ''
|
|
95
|
+
const key = auth.replace(/^Bearer\s+/i, '').trim()
|
|
96
|
+
if (/^pk_[a-z0-9]+\.[a-z0-9]+$/i.test(key)) return key
|
|
97
|
+
} catch {}
|
|
98
|
+
return undefined
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// ── MCP transport (JSON or SSE) ─────────────────────────────────────────────────
|
|
102
|
+
const parseMcpBody = (body, contentType) => {
|
|
103
|
+
if (contentType && contentType.includes('text/event-stream')) {
|
|
104
|
+
let last = null
|
|
105
|
+
for (const frame of body.split(/\r?\n\r?\n/)) {
|
|
106
|
+
const data = frame
|
|
107
|
+
.split(/\r?\n/)
|
|
108
|
+
.filter((line) => line.startsWith('data:'))
|
|
109
|
+
.map((line) => line.slice(5).trimStart())
|
|
110
|
+
.join('\n')
|
|
111
|
+
.trim()
|
|
112
|
+
if (!data) continue
|
|
113
|
+
try {
|
|
114
|
+
last = JSON.parse(data)
|
|
115
|
+
} catch {}
|
|
116
|
+
}
|
|
117
|
+
if (!last) throw new Error('empty SSE response')
|
|
118
|
+
return last
|
|
119
|
+
}
|
|
120
|
+
return JSON.parse(body)
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const callTool = async (apiKey, name, args) => {
|
|
124
|
+
const response = await fetch(MCP_URL, {
|
|
125
|
+
method: 'POST',
|
|
126
|
+
headers: {
|
|
127
|
+
'Content-Type': 'application/json',
|
|
128
|
+
Accept: 'application/json, text/event-stream',
|
|
129
|
+
Authorization: `Bearer ${apiKey}`,
|
|
130
|
+
},
|
|
131
|
+
body: JSON.stringify({ jsonrpc: '2.0', id: Date.now(), method: 'tools/call', params: { name, arguments: args } }),
|
|
132
|
+
signal: AbortSignal.timeout(NET_TIMEOUT_MS),
|
|
133
|
+
})
|
|
134
|
+
const text = await response.text()
|
|
135
|
+
if (!response.ok) throw new Error(`Pushary MCP ${response.status}`)
|
|
136
|
+
const rpc = parseMcpBody(text, response.headers.get('content-type'))
|
|
137
|
+
if (rpc.error) throw new Error(rpc.error.message || 'Pushary MCP error')
|
|
138
|
+
const payload = rpc.result?.content?.[0]?.text
|
|
139
|
+
if (!payload) throw new Error('empty Pushary response')
|
|
140
|
+
return JSON.parse(payload)
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const getJson = async (path, apiKey, timeoutMs) => {
|
|
144
|
+
const response = await fetch(`${BASE_URL}${path}`, {
|
|
145
|
+
headers: { Authorization: `Bearer ${apiKey}` },
|
|
146
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
147
|
+
})
|
|
148
|
+
if (!response.ok) throw new Error(`GET ${path} ${response.status}`)
|
|
149
|
+
return response.json()
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// ── Policy (mirrors @pushary/agent-hooks policy.ts) ──────────────────────────────
|
|
153
|
+
const isPolicyConfig = (d) =>
|
|
154
|
+
!!d && typeof d === 'object' && Array.isArray(d.policies) && typeof d.defaultTimeoutSeconds === 'number' && typeof d.defaultTimeoutAction === 'string'
|
|
155
|
+
|
|
156
|
+
const policyCacheFile = (apiKey) => join(tmpdir(), `pushary-policy-${createHash('sha256').update(apiKey).digest('hex').slice(0, 12)}.json`)
|
|
157
|
+
|
|
158
|
+
const getPolicy = async (apiKey) => {
|
|
159
|
+
const path = policyCacheFile(apiKey)
|
|
160
|
+
let stale = null
|
|
161
|
+
if (existsSync(path)) {
|
|
162
|
+
try {
|
|
163
|
+
const cached = JSON.parse(readFileSync(path, 'utf-8'))
|
|
164
|
+
if (isPolicyConfig(cached)) {
|
|
165
|
+
if (!cached._cachedAt || Date.now() - cached._cachedAt < POLICY_CACHE_TTL_MS) return cached
|
|
166
|
+
stale = cached
|
|
167
|
+
}
|
|
168
|
+
} catch {}
|
|
169
|
+
}
|
|
170
|
+
try {
|
|
171
|
+
const fresh = await withRetry(async () => {
|
|
172
|
+
const raw = await getJson('/api/mcp/policy', apiKey, POLICY_TIMEOUT_MS)
|
|
173
|
+
if (!isPolicyConfig(raw)) throw new Error('invalid policy')
|
|
174
|
+
return raw
|
|
175
|
+
}, 2)
|
|
176
|
+
try {
|
|
177
|
+
writeFileSync(path, JSON.stringify({ ...fresh, _cachedAt: Date.now() }), 'utf-8')
|
|
178
|
+
} catch {}
|
|
179
|
+
return fresh
|
|
180
|
+
} catch (error) {
|
|
181
|
+
if (stale) return stale
|
|
182
|
+
throw error
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const resolvePolicy = (config, toolName, modeOverride) => {
|
|
187
|
+
const base =
|
|
188
|
+
config.policies.find((p) => p.tool === toolName) ??
|
|
189
|
+
config.policies.find((p) => p.tool === '*') ??
|
|
190
|
+
{
|
|
191
|
+
tool: toolName,
|
|
192
|
+
timeoutSeconds: config.defaultTimeoutSeconds,
|
|
193
|
+
timeoutAction: config.defaultTimeoutAction,
|
|
194
|
+
mode: config.defaultMode ?? 'push_first',
|
|
195
|
+
pushFirstSeconds: config.defaultPushFirstSeconds ?? 20,
|
|
196
|
+
}
|
|
197
|
+
const effective = modeOverride ?? config.modeOverride
|
|
198
|
+
return effective ? { ...base, mode: effective } : base
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const APPROVAL_MODES = ['push_only', 'terminal_only', 'push_first', 'notify_only']
|
|
202
|
+
const fetchModeState = async (apiKey, sessionId) => {
|
|
203
|
+
try {
|
|
204
|
+
const path = sessionId ? `/api/mcp/mode?session=${encodeURIComponent(sessionId)}` : '/api/mcp/mode'
|
|
205
|
+
const data = await getJson(path, apiKey, MODE_TIMEOUT_MS)
|
|
206
|
+
const mode = data?.override?.mode
|
|
207
|
+
return { mode: APPROVAL_MODES.includes(mode) ? mode : null, kill: data?.kill === true }
|
|
208
|
+
} catch {
|
|
209
|
+
return { mode: null, kill: false }
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// ── ask / wait ───────────────────────────────────────────────────────────────
|
|
214
|
+
const askArgs = (command, project, ident) => ({
|
|
215
|
+
question: `Allow this command?\n\n${command}`,
|
|
216
|
+
type: 'confirm',
|
|
217
|
+
context: `Cursor agent wants to run this in ${project}`,
|
|
218
|
+
agentName: ident.agentName,
|
|
219
|
+
sessionId: ident.sessionId,
|
|
220
|
+
machineId: ident.machineId,
|
|
221
|
+
toolName: 'Bash',
|
|
222
|
+
wait: false,
|
|
223
|
+
})
|
|
224
|
+
|
|
225
|
+
const pollForAnswer = async (apiKey, correlationId, deadlineMs) => {
|
|
226
|
+
while (Date.now() < deadlineMs) {
|
|
227
|
+
const remaining = clamp(deadlineMs - Date.now(), 1_000, WAIT_CHUNK_MS)
|
|
228
|
+
try {
|
|
229
|
+
const answer = await callTool(apiKey, 'wait_for_answer', { correlationId, timeoutMs: remaining })
|
|
230
|
+
if (answer?.answered) return answer
|
|
231
|
+
} catch {
|
|
232
|
+
if (Date.now() + POLL_GAP_MS >= deadlineMs) break
|
|
233
|
+
await sleep(POLL_GAP_MS)
|
|
234
|
+
continue
|
|
235
|
+
}
|
|
236
|
+
if (Date.now() + POLL_GAP_MS >= deadlineMs) break
|
|
237
|
+
await sleep(POLL_GAP_MS)
|
|
238
|
+
}
|
|
239
|
+
return { answered: false }
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
const fromTimeoutAction = (action, deniedReason) =>
|
|
243
|
+
action === 'approve' ? ALLOW : action === 'deny' ? deny(deniedReason) : ask()
|
|
244
|
+
|
|
245
|
+
const DENIED = 'The user denied this command via a Pushary push approval. Do not run it — propose an alternative or ask how to proceed.'
|
|
246
|
+
|
|
247
|
+
// push_only: wait up to the policy timeout, then apply the timeout action.
|
|
248
|
+
const handlePushOnly = async (apiKey, command, project, ident, timeoutSeconds, timeoutAction) => {
|
|
249
|
+
let asked
|
|
250
|
+
try {
|
|
251
|
+
asked = await withRetry(() => callTool(apiKey, 'ask_user', askArgs(command, project, ident)), 3)
|
|
252
|
+
} catch {
|
|
253
|
+
return fromTimeoutAction(timeoutAction, 'Push notification failed; denied per your Pushary policy.')
|
|
254
|
+
}
|
|
255
|
+
if (!asked?.correlationId) return ask()
|
|
256
|
+
|
|
257
|
+
const realMs = Math.max(timeoutSeconds, 1) * 1000
|
|
258
|
+
const cap = Math.min(realMs, MAX_BLOCK_MS)
|
|
259
|
+
const answer = await pollForAnswer(apiKey, asked.correlationId, Date.now() + cap)
|
|
260
|
+
if (answer.answered) return answer.value === 'yes' ? ALLOW : deny(DENIED)
|
|
261
|
+
|
|
262
|
+
// If Cursor's hook limit cut us off before the configured timeout, hand off to
|
|
263
|
+
// Cursor's own prompt rather than misapplying the policy's timeout action.
|
|
264
|
+
if (cap >= realMs) return fromTimeoutAction(timeoutAction, 'No response within the approval timeout; denied per your Pushary policy.')
|
|
265
|
+
return ask()
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// push_first: race the push for a short window, then fall back to Cursor's prompt.
|
|
269
|
+
const handlePushFirst = async (apiKey, command, project, ident, pushFirstSeconds) => {
|
|
270
|
+
let asked
|
|
271
|
+
try {
|
|
272
|
+
asked = await withRetry(() => callTool(apiKey, 'ask_user', askArgs(command, project, ident)), 3)
|
|
273
|
+
} catch {
|
|
274
|
+
return ask()
|
|
275
|
+
}
|
|
276
|
+
if (!asked?.correlationId) return ask()
|
|
277
|
+
|
|
278
|
+
const cap = Math.min(Math.max(pushFirstSeconds, 1) * 1000, MAX_BLOCK_MS)
|
|
279
|
+
const answer = await pollForAnswer(apiKey, asked.correlationId, Date.now() + cap)
|
|
280
|
+
if (answer.answered) return answer.value === 'yes' ? ALLOW : deny(DENIED)
|
|
281
|
+
return ask('Sent to your phone via Pushary — you can also approve here.')
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// notify_only: fire an awareness notification, let Cursor's prompt decide.
|
|
285
|
+
const handleNotifyOnly = async (apiKey, command, project, ident) => {
|
|
286
|
+
try {
|
|
287
|
+
await callTool(apiKey, 'send_notification', {
|
|
288
|
+
title: 'Agent needs approval',
|
|
289
|
+
body: command.slice(0, 180),
|
|
290
|
+
agentName: ident.agentName,
|
|
291
|
+
sessionId: ident.sessionId,
|
|
292
|
+
machineId: ident.machineId,
|
|
293
|
+
})
|
|
294
|
+
} catch {}
|
|
295
|
+
return ask()
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
const main = async () => {
|
|
299
|
+
let input
|
|
300
|
+
try {
|
|
301
|
+
const raw = await readStdin()
|
|
302
|
+
input = raw.trim() ? JSON.parse(raw) : {}
|
|
303
|
+
} catch {
|
|
304
|
+
return respond(ask())
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
const command = typeof input.command === 'string' ? input.command.trim() : ''
|
|
308
|
+
if (!command) return respond(ask())
|
|
309
|
+
|
|
310
|
+
const apiKey = resolveApiKey()
|
|
311
|
+
if (!apiKey) {
|
|
312
|
+
return respond(
|
|
313
|
+
ask('Pushary is not configured: set the PUSHARY_API_KEY environment variable (get a key at https://pushary.com) to route this approval to your phone.')
|
|
314
|
+
)
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
const project = basename(input.cwd || process.cwd()) || 'workspace'
|
|
318
|
+
const sessionId = typeof input.conversation_id === 'string' ? input.conversation_id : undefined
|
|
319
|
+
const ident = { agentName: `Cursor - ${project}`, sessionId, machineId: getMachineId() }
|
|
320
|
+
|
|
321
|
+
try {
|
|
322
|
+
const [policy, modeState] = await Promise.all([getPolicy(apiKey), fetchModeState(apiKey, sessionId)])
|
|
323
|
+
|
|
324
|
+
if (modeState.kill) return respond(deny('Stopped by user — this agent was halted from Pushary. Do not run this command.'))
|
|
325
|
+
|
|
326
|
+
const tool = resolvePolicy(policy, 'Bash', modeState.mode)
|
|
327
|
+
if (tool.timeoutSeconds === 0 && tool.timeoutAction === 'approve') return respond(ALLOW)
|
|
328
|
+
|
|
329
|
+
switch (tool.mode) {
|
|
330
|
+
case 'terminal_only':
|
|
331
|
+
return respond(ask())
|
|
332
|
+
case 'notify_only':
|
|
333
|
+
return respond(await handleNotifyOnly(apiKey, command, project, ident))
|
|
334
|
+
case 'push_only':
|
|
335
|
+
return respond(await handlePushOnly(apiKey, command, project, ident, tool.timeoutSeconds, tool.timeoutAction))
|
|
336
|
+
case 'push_first':
|
|
337
|
+
default:
|
|
338
|
+
return respond(await handlePushFirst(apiKey, command, project, ident, tool.pushFirstSeconds))
|
|
339
|
+
}
|
|
340
|
+
} catch (error) {
|
|
341
|
+
process.stderr.write(`[pushary-gate] ${error?.message ?? error}\n`)
|
|
342
|
+
return respond(ask())
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
main().catch((error) => {
|
|
347
|
+
process.stderr.write(`[pushary-gate] fatal: ${error?.message ?? error}\n`)
|
|
348
|
+
respond(ask())
|
|
349
|
+
})
|