claude-remote-approver 0.1.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/LICENSE +21 -0
- package/README.md +210 -0
- package/bin/cli.mjs +151 -0
- package/package.json +36 -0
- package/src/config.mjs +42 -0
- package/src/hook.mjs +84 -0
- package/src/ntfy.mjs +119 -0
- package/src/setup.mjs +75 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026
|
|
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.
|
package/README.md
ADDED
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
# claude-remote-approver
|
|
2
|
+
|
|
3
|
+
Approve or deny Claude Code permission prompts from your phone.
|
|
4
|
+
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
## Problem
|
|
8
|
+
|
|
9
|
+
Claude Code asks for permission before running tools like `Bash`, `Write`, and `Edit`. These prompts require you to be sitting at your terminal. If you step away, Claude Code stalls until you come back and press "y".
|
|
10
|
+
|
|
11
|
+
**claude-remote-approver** sends each permission prompt as a push notification to your phone via [ntfy.sh](https://ntfy.sh). You tap **Approve** or **Deny**, and Claude Code continues immediately -- no terminal required.
|
|
12
|
+
|
|
13
|
+
## How it works
|
|
14
|
+
|
|
15
|
+
```
|
|
16
|
+
Claude Code
|
|
17
|
+
│
|
|
18
|
+
│ PermissionRequest hook (stdin JSON)
|
|
19
|
+
▼
|
|
20
|
+
hook.mjs
|
|
21
|
+
│
|
|
22
|
+
├──POST──▶ ntfy.sh/<topic> ──push──▶ Phone (ntfy app)
|
|
23
|
+
│ │
|
|
24
|
+
│ Approve / Deny tap
|
|
25
|
+
│ │
|
|
26
|
+
└──SSE───▶ ntfy.sh/<topic>-response ◀──POST──┘
|
|
27
|
+
│
|
|
28
|
+
│ stdout JSON: { "behavior": "allow" } or { "behavior": "deny" }
|
|
29
|
+
▼
|
|
30
|
+
Claude Code continues or stops
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
1. Claude Code invokes the hook, piping the tool request as JSON to stdin.
|
|
34
|
+
2. `hook.mjs` sends a notification to your ntfy topic with **Approve** and **Deny** action buttons.
|
|
35
|
+
3. The hook subscribes to a response topic (`<topic>-response`) via server-sent events.
|
|
36
|
+
4. When you tap a button on your phone, ntfy.sh publishes your decision to the response topic.
|
|
37
|
+
5. The hook reads the decision, writes `{"behavior":"allow"}` or `{"behavior":"deny"}` to stdout, and exits.
|
|
38
|
+
6. Claude Code proceeds accordingly.
|
|
39
|
+
|
|
40
|
+
## Quick Start
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
# 1. Install the ntfy app on your phone
|
|
44
|
+
# iOS: https://apps.apple.com/app/ntfy/id1625396347
|
|
45
|
+
# Android: https://play.google.com/store/apps/details?id=io.heckel.ntfy
|
|
46
|
+
|
|
47
|
+
# 2. Install claude-remote-approver
|
|
48
|
+
npm install -g claude-remote-approver
|
|
49
|
+
|
|
50
|
+
# 3. Run setup
|
|
51
|
+
claude-remote-approver setup
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
Setup prints a topic name. Subscribe to that topic in the ntfy app, and you are done.
|
|
55
|
+
|
|
56
|
+
## Installation
|
|
57
|
+
|
|
58
|
+
```bash
|
|
59
|
+
npm install -g claude-remote-approver
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
Requires Node.js 18 or later.
|
|
63
|
+
|
|
64
|
+
## Setup
|
|
65
|
+
|
|
66
|
+
```bash
|
|
67
|
+
claude-remote-approver setup
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
This command does three things:
|
|
71
|
+
|
|
72
|
+
1. **Generates a unique topic** -- a random string like `cra-a1b2c3d4e5f6...` (128 bits of entropy).
|
|
73
|
+
2. **Creates a config file** at `~/.claude-remote-approver.json` with your topic and default settings. The file is created with permission `0600` (owner read/write only).
|
|
74
|
+
3. **Registers the hook** in Claude Code's `~/.claude/settings.json` under `hooks.PermissionRequest`. If a previous hook entry from this tool exists, it is replaced.
|
|
75
|
+
|
|
76
|
+
After running setup, open the ntfy app on your phone and subscribe to the topic printed in the terminal.
|
|
77
|
+
|
|
78
|
+
## Usage
|
|
79
|
+
|
|
80
|
+
### `setup`
|
|
81
|
+
|
|
82
|
+
Configure the tool and register the hook with Claude Code.
|
|
83
|
+
|
|
84
|
+
```bash
|
|
85
|
+
claude-remote-approver setup
|
|
86
|
+
# Setup complete. Topic: cra-<hex>
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
### `test`
|
|
90
|
+
|
|
91
|
+
Send a test notification to verify your setup is working.
|
|
92
|
+
|
|
93
|
+
```bash
|
|
94
|
+
claude-remote-approver test
|
|
95
|
+
# Test notification sent successfully.
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
If you see the notification on your phone, everything is configured correctly.
|
|
99
|
+
|
|
100
|
+
### `status`
|
|
101
|
+
|
|
102
|
+
Display the current configuration.
|
|
103
|
+
|
|
104
|
+
```bash
|
|
105
|
+
claude-remote-approver status
|
|
106
|
+
# Topic: cra-a1b2c3d4...
|
|
107
|
+
# Server: https://ntfy.sh
|
|
108
|
+
# Timeout: 120s
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
### `hook`
|
|
112
|
+
|
|
113
|
+
Internal command. Claude Code calls this automatically via the registered hook. It reads a JSON payload from stdin and writes a decision to stdout. You do not need to run this manually.
|
|
114
|
+
|
|
115
|
+
### Flags
|
|
116
|
+
|
|
117
|
+
```
|
|
118
|
+
--help, -h Show usage information
|
|
119
|
+
--version, -v Print version number
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
## Configuration
|
|
123
|
+
|
|
124
|
+
Config file location: `~/.claude-remote-approver.json`
|
|
125
|
+
|
|
126
|
+
```json
|
|
127
|
+
{
|
|
128
|
+
"topic": "cra-a1b2c3d4e5f67890abcdef1234567890",
|
|
129
|
+
"ntfyServer": "https://ntfy.sh",
|
|
130
|
+
"timeout": 120,
|
|
131
|
+
"autoApprove": [],
|
|
132
|
+
"autoDeny": []
|
|
133
|
+
}
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
| Field | Type | Default | Description |
|
|
137
|
+
|---|---|---|---|
|
|
138
|
+
| `topic` | `string` | `""` | Your unique ntfy topic. Generated by `setup`. |
|
|
139
|
+
| `ntfyServer` | `string` | `"https://ntfy.sh"` | The ntfy server URL. Change this if you self-host. |
|
|
140
|
+
| `timeout` | `number` | `120` | Seconds to wait for a response before auto-denying. |
|
|
141
|
+
| `autoApprove` | `string[]` | `[]` | Reserved for future use. |
|
|
142
|
+
| `autoDeny` | `string[]` | `[]` | Reserved for future use. |
|
|
143
|
+
|
|
144
|
+
### Using a self-hosted ntfy server
|
|
145
|
+
|
|
146
|
+
Edit `~/.claude-remote-approver.json` and set `ntfyServer` to your server URL:
|
|
147
|
+
|
|
148
|
+
```json
|
|
149
|
+
{
|
|
150
|
+
"ntfyServer": "https://ntfy.example.com"
|
|
151
|
+
}
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
Then subscribe to the topic on your self-hosted server in the ntfy app.
|
|
155
|
+
|
|
156
|
+
## How ntfy.sh works
|
|
157
|
+
|
|
158
|
+
[ntfy.sh](https://ntfy.sh) is a simple HTTP-based pub-sub notification service. Any client can publish a message to a topic by sending a POST request, and any client subscribed to that topic receives the message as a push notification.
|
|
159
|
+
|
|
160
|
+
claude-remote-approver uses two topics:
|
|
161
|
+
|
|
162
|
+
- **`<topic>`** -- The hook publishes permission requests here. Your phone receives these as notifications with action buttons.
|
|
163
|
+
- **`<topic>-response`** -- When you tap Approve or Deny, the ntfy app sends an HTTP POST to this topic. The hook subscribes to it via SSE (server-sent events) and reads your decision.
|
|
164
|
+
|
|
165
|
+
No account is required. Topics are identified by name only, which is why the generated topic contains 128 bits of randomness.
|
|
166
|
+
|
|
167
|
+
For more details, see the [ntfy documentation](https://docs.ntfy.sh).
|
|
168
|
+
|
|
169
|
+
## Security
|
|
170
|
+
|
|
171
|
+
### Topic entropy
|
|
172
|
+
|
|
173
|
+
The topic name is generated using `crypto.randomBytes(16)`, producing 128 bits of randomness (32 hex characters). This makes the topic effectively unguessable.
|
|
174
|
+
|
|
175
|
+
### File permissions
|
|
176
|
+
|
|
177
|
+
The config file (`~/.claude-remote-approver.json`) is written with mode `0600` -- only the file owner can read or write it. This prevents other users on the system from reading your topic name.
|
|
178
|
+
|
|
179
|
+
### Self-hosting recommendation
|
|
180
|
+
|
|
181
|
+
The public ntfy.sh server is convenient but means your permission request details (tool names, commands, file paths) pass through a third-party server. For sensitive work, consider [self-hosting ntfy](https://docs.ntfy.sh/install/) and setting `ntfyServer` in your config to your own server.
|
|
182
|
+
|
|
183
|
+
### Timeout behavior
|
|
184
|
+
|
|
185
|
+
If no response is received within the configured timeout (default: 120 seconds), the hook automatically **denies** the request. This fail-closed design ensures Claude Code does not proceed without explicit approval.
|
|
186
|
+
|
|
187
|
+
## Disclaimer
|
|
188
|
+
|
|
189
|
+
**Use at your own risk.** This tool automates permission control for Claude Code. Misuse or misconfiguration may result in unintended code execution, file modification, or data loss.
|
|
190
|
+
|
|
191
|
+
The authors are not responsible for any damages or losses arising from the use of this tool, including but not limited to:
|
|
192
|
+
|
|
193
|
+
- Accidental approval of dangerous commands (e.g., mistapping Approve on your phone)
|
|
194
|
+
- Unintended denial of safe commands (e.g., timeout, network issues)
|
|
195
|
+
- Security breaches if the topic name is compromised
|
|
196
|
+
|
|
197
|
+
**Not a substitute for careful review.** The push notification shows the tool name and a brief summary, but not the full context of what Claude Code is doing. Always review what you are approving.
|
|
198
|
+
|
|
199
|
+
This software is provided "AS IS" without warranty of any kind, as stated in the [MIT License](LICENSE).
|
|
200
|
+
|
|
201
|
+
## Requirements
|
|
202
|
+
|
|
203
|
+
- **Node.js** >= 18.0.0
|
|
204
|
+
- **ntfy app** on your phone
|
|
205
|
+
- [iOS (App Store)](https://apps.apple.com/app/ntfy/id1625396347)
|
|
206
|
+
- [Android (Google Play)](https://play.google.com/store/apps/details?id=io.heckel.ntfy)
|
|
207
|
+
|
|
208
|
+
## License
|
|
209
|
+
|
|
210
|
+
[MIT](LICENSE)
|
package/bin/cli.mjs
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* CLI entry point for claude-remote-approver.
|
|
5
|
+
*
|
|
6
|
+
* Subcommands: setup | test | status | hook
|
|
7
|
+
* All I/O goes through the injected `deps` object so the module is fully testable.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { fileURLToPath } from "node:url";
|
|
11
|
+
import { realpathSync } from "node:fs";
|
|
12
|
+
|
|
13
|
+
// ---------------------------------------------------------------------------
|
|
14
|
+
// main
|
|
15
|
+
// ---------------------------------------------------------------------------
|
|
16
|
+
|
|
17
|
+
export async function main(args, deps) {
|
|
18
|
+
if (args.includes("--help") || args.includes("-h")) {
|
|
19
|
+
deps.stdout.write("Usage: claude-remote-approver <command>\n\nCommands:\n setup Set up remote approval\n test Send a test notification\n status Show current configuration\n hook Process a Claude Code hook (internal)\n");
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
if (args.includes("--version") || args.includes("-v")) {
|
|
23
|
+
deps.stdout.write("0.1.0\n");
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const command = args[0];
|
|
28
|
+
|
|
29
|
+
switch (command) {
|
|
30
|
+
case "setup": {
|
|
31
|
+
const result = await deps.runSetup(deps);
|
|
32
|
+
deps.stdout.write(`Setup complete. Topic: ${result.topic}\n`);
|
|
33
|
+
break;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
case "test": {
|
|
37
|
+
const config = deps.loadConfig();
|
|
38
|
+
if (!config.topic) {
|
|
39
|
+
deps.stderr.write("Error: No topic configured. Run 'claude-remote-approver setup' first.\n");
|
|
40
|
+
break;
|
|
41
|
+
}
|
|
42
|
+
try {
|
|
43
|
+
await deps.sendNotification({
|
|
44
|
+
server: config.ntfyServer,
|
|
45
|
+
topic: config.topic,
|
|
46
|
+
title: "Claude Remote Approver",
|
|
47
|
+
message: "Test notification - if you see this, setup is working!",
|
|
48
|
+
actions: [],
|
|
49
|
+
requestId: "test",
|
|
50
|
+
});
|
|
51
|
+
deps.stdout.write("Test notification sent successfully.\n");
|
|
52
|
+
} catch (err) {
|
|
53
|
+
deps.stderr.write(`Error: Failed to send notification: ${err.message}\n`);
|
|
54
|
+
}
|
|
55
|
+
break;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
case "status": {
|
|
59
|
+
const config = deps.loadConfig();
|
|
60
|
+
deps.stdout.write(`Topic: ${config.topic}\n`);
|
|
61
|
+
deps.stdout.write(`Server: ${config.ntfyServer}\n`);
|
|
62
|
+
deps.stdout.write(`Timeout: ${config.timeout}s\n`);
|
|
63
|
+
break;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
case "hook": {
|
|
67
|
+
let input;
|
|
68
|
+
try {
|
|
69
|
+
input = JSON.parse(deps.stdin);
|
|
70
|
+
} catch {
|
|
71
|
+
const deny = { hookSpecificOutput: { decision: { behavior: "deny" } } };
|
|
72
|
+
deps.stdout.write(JSON.stringify(deny) + "\n");
|
|
73
|
+
break;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
let result;
|
|
77
|
+
try {
|
|
78
|
+
result = await deps.processHook(input, deps);
|
|
79
|
+
} catch {
|
|
80
|
+
const deny = { hookSpecificOutput: { decision: { behavior: "deny" } } };
|
|
81
|
+
deps.stdout.write(JSON.stringify(deny) + "\n");
|
|
82
|
+
break;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
deps.stdout.write(JSON.stringify(result) + "\n");
|
|
86
|
+
break;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
default: {
|
|
90
|
+
deps.stderr.write(
|
|
91
|
+
"Usage: claude-remote-approver <command>\n\nCommands:\n setup Configure topic and register hook\n test Send a test notification\n status Show current configuration\n hook Process a Claude Code hook (reads JSON from stdin)\n",
|
|
92
|
+
);
|
|
93
|
+
deps.exit(1);
|
|
94
|
+
break;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// ---------------------------------------------------------------------------
|
|
100
|
+
// Auto-execute when run directly (not imported)
|
|
101
|
+
// ---------------------------------------------------------------------------
|
|
102
|
+
|
|
103
|
+
const isMain =
|
|
104
|
+
typeof process !== "undefined" &&
|
|
105
|
+
process.argv[1] &&
|
|
106
|
+
(() => {
|
|
107
|
+
try {
|
|
108
|
+
return realpathSync(fileURLToPath(import.meta.url)) === realpathSync(process.argv[1]);
|
|
109
|
+
} catch {
|
|
110
|
+
return false;
|
|
111
|
+
}
|
|
112
|
+
})();
|
|
113
|
+
|
|
114
|
+
if (isMain) {
|
|
115
|
+
const { loadConfig, saveConfig, generateTopic } = await import(
|
|
116
|
+
"../src/config.mjs"
|
|
117
|
+
);
|
|
118
|
+
const { sendNotification, waitForResponse, formatToolInfo } = await import(
|
|
119
|
+
"../src/ntfy.mjs"
|
|
120
|
+
);
|
|
121
|
+
const { processHook } = await import("../src/hook.mjs");
|
|
122
|
+
const { runSetup } = await import("../src/setup.mjs");
|
|
123
|
+
|
|
124
|
+
const args = process.argv.slice(2);
|
|
125
|
+
|
|
126
|
+
let stdinData = "";
|
|
127
|
+
if (!process.stdin.isTTY) {
|
|
128
|
+
const chunks = [];
|
|
129
|
+
for await (const chunk of process.stdin) {
|
|
130
|
+
chunks.push(chunk);
|
|
131
|
+
}
|
|
132
|
+
stdinData = Buffer.concat(chunks).toString("utf-8");
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const deps = {
|
|
136
|
+
loadConfig,
|
|
137
|
+
saveConfig,
|
|
138
|
+
generateTopic,
|
|
139
|
+
sendNotification,
|
|
140
|
+
waitForResponse,
|
|
141
|
+
formatToolInfo,
|
|
142
|
+
processHook,
|
|
143
|
+
runSetup,
|
|
144
|
+
stdout: process.stdout,
|
|
145
|
+
stderr: process.stderr,
|
|
146
|
+
stdin: stdinData,
|
|
147
|
+
exit: process.exit,
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
await main(args, deps);
|
|
151
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "claude-remote-approver",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Approve or deny Claude Code permission prompts remotely from your phone via ntfy.sh",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"claude-remote-approver": "./bin/cli.mjs"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"bin/",
|
|
11
|
+
"src/",
|
|
12
|
+
"LICENSE",
|
|
13
|
+
"README.md"
|
|
14
|
+
],
|
|
15
|
+
"scripts": {
|
|
16
|
+
"test": "node --test test/**/*.test.mjs"
|
|
17
|
+
},
|
|
18
|
+
"keywords": [
|
|
19
|
+
"claude",
|
|
20
|
+
"claude-code",
|
|
21
|
+
"remote",
|
|
22
|
+
"approval",
|
|
23
|
+
"ntfy",
|
|
24
|
+
"notification",
|
|
25
|
+
"hook"
|
|
26
|
+
],
|
|
27
|
+
"author": "Yuuichi Eguchi",
|
|
28
|
+
"license": "MIT",
|
|
29
|
+
"engines": {
|
|
30
|
+
"node": ">=18.0.0"
|
|
31
|
+
},
|
|
32
|
+
"repository": {
|
|
33
|
+
"type": "git",
|
|
34
|
+
"url": "git+https://github.com/eguchiyuuichi/claude-remote-approver.git"
|
|
35
|
+
}
|
|
36
|
+
}
|
package/src/config.mjs
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import crypto from "node:crypto";
|
|
5
|
+
|
|
6
|
+
export const CONFIG_PATH = path.join(os.homedir(), ".claude-remote-approver.json");
|
|
7
|
+
|
|
8
|
+
export const DEFAULT_CONFIG = {
|
|
9
|
+
topic: "",
|
|
10
|
+
ntfyServer: "https://ntfy.sh",
|
|
11
|
+
timeout: 120,
|
|
12
|
+
// autoApprove/autoDeny are reserved for future use and not yet implemented
|
|
13
|
+
autoApprove: [],
|
|
14
|
+
autoDeny: [],
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export function loadConfig(configPath = CONFIG_PATH) {
|
|
18
|
+
try {
|
|
19
|
+
const raw = fs.readFileSync(configPath, "utf-8");
|
|
20
|
+
const fileConfig = JSON.parse(raw);
|
|
21
|
+
const config = { ...DEFAULT_CONFIG, ...fileConfig };
|
|
22
|
+
if (typeof config.topic !== "string") config.topic = DEFAULT_CONFIG.topic;
|
|
23
|
+
if (typeof config.ntfyServer !== "string") config.ntfyServer = DEFAULT_CONFIG.ntfyServer;
|
|
24
|
+
if (typeof config.timeout !== "number" || config.timeout <= 0) config.timeout = DEFAULT_CONFIG.timeout;
|
|
25
|
+
if (!Array.isArray(config.autoApprove)) config.autoApprove = DEFAULT_CONFIG.autoApprove;
|
|
26
|
+
if (!Array.isArray(config.autoDeny)) config.autoDeny = DEFAULT_CONFIG.autoDeny;
|
|
27
|
+
return config;
|
|
28
|
+
} catch (err) {
|
|
29
|
+
if (err.code === "ENOENT") {
|
|
30
|
+
return { ...DEFAULT_CONFIG };
|
|
31
|
+
}
|
|
32
|
+
throw err;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function saveConfig(config, configPath = CONFIG_PATH) {
|
|
37
|
+
fs.writeFileSync(configPath, JSON.stringify(config, null, 2), { mode: 0o600 });
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function generateTopic() {
|
|
41
|
+
return `cra-${crypto.randomBytes(16).toString("hex")}`;
|
|
42
|
+
}
|
package/src/hook.mjs
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
// src/hook.mjs
|
|
2
|
+
|
|
3
|
+
import crypto from "node:crypto";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Build ntfy action buttons for Approve / Deny.
|
|
7
|
+
*
|
|
8
|
+
* @param {string} server - ntfy server URL
|
|
9
|
+
* @param {string} topic - ntfy topic
|
|
10
|
+
* @param {string} requestId - Unique request identifier
|
|
11
|
+
* @returns {Array<object>} Array of 2 action objects
|
|
12
|
+
*/
|
|
13
|
+
export function buildActions(server, topic, requestId) {
|
|
14
|
+
const url = `${server}/${topic}-response`;
|
|
15
|
+
return [
|
|
16
|
+
{
|
|
17
|
+
action: "http",
|
|
18
|
+
label: "Approve",
|
|
19
|
+
url,
|
|
20
|
+
body: JSON.stringify({ requestId, approved: true }),
|
|
21
|
+
method: "POST",
|
|
22
|
+
headers: { "Content-Type": "application/json" },
|
|
23
|
+
},
|
|
24
|
+
{
|
|
25
|
+
action: "http",
|
|
26
|
+
label: "Deny",
|
|
27
|
+
url,
|
|
28
|
+
body: JSON.stringify({ requestId, approved: false }),
|
|
29
|
+
method: "POST",
|
|
30
|
+
headers: { "Content-Type": "application/json" },
|
|
31
|
+
},
|
|
32
|
+
];
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Process a Claude Code hook request.
|
|
37
|
+
*
|
|
38
|
+
* @param {object} input - The hook input payload
|
|
39
|
+
* @param {object} deps - Injected dependencies
|
|
40
|
+
* @param {Function} deps.loadConfig
|
|
41
|
+
* @param {Function} deps.sendNotification
|
|
42
|
+
* @param {Function} deps.waitForResponse
|
|
43
|
+
* @param {Function} deps.formatToolInfo
|
|
44
|
+
* @returns {Promise<object>} Decision JSON
|
|
45
|
+
*/
|
|
46
|
+
export async function processHook(input, { loadConfig, sendNotification, waitForResponse, formatToolInfo }) {
|
|
47
|
+
const config = loadConfig();
|
|
48
|
+
|
|
49
|
+
if (!config.topic) {
|
|
50
|
+
return { hookSpecificOutput: { decision: { behavior: "deny" } } };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const requestId = crypto.randomUUID();
|
|
54
|
+
const { title, message } = formatToolInfo(input);
|
|
55
|
+
const actions = buildActions(config.ntfyServer, config.topic, requestId);
|
|
56
|
+
|
|
57
|
+
try {
|
|
58
|
+
await sendNotification({
|
|
59
|
+
server: config.ntfyServer,
|
|
60
|
+
topic: config.topic,
|
|
61
|
+
title,
|
|
62
|
+
message,
|
|
63
|
+
actions,
|
|
64
|
+
requestId,
|
|
65
|
+
});
|
|
66
|
+
} catch {
|
|
67
|
+
return { hookSpecificOutput: { decision: { behavior: "deny" } } };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
let response;
|
|
71
|
+
try {
|
|
72
|
+
response = await waitForResponse({
|
|
73
|
+
server: config.ntfyServer,
|
|
74
|
+
topic: config.topic,
|
|
75
|
+
requestId,
|
|
76
|
+
timeout: config.timeout * 1000,
|
|
77
|
+
});
|
|
78
|
+
} catch {
|
|
79
|
+
return { hookSpecificOutput: { decision: { behavior: "deny" } } };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const behavior = response.approved ? "allow" : "deny";
|
|
83
|
+
return { hookSpecificOutput: { decision: { behavior } } };
|
|
84
|
+
}
|
package/src/ntfy.mjs
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
// src/ntfy.mjs
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Send a push notification via ntfy.
|
|
5
|
+
*
|
|
6
|
+
* @param {{ server: string, topic: string, title: string, message: string, actions: unknown[], requestId: string }} params
|
|
7
|
+
* @returns {Promise<Response>}
|
|
8
|
+
*/
|
|
9
|
+
export async function sendNotification({ server, topic, title, message, actions, requestId }) {
|
|
10
|
+
const baseUrl = server.replace(/\/+$/, '');
|
|
11
|
+
const url = `${baseUrl}/${topic}`;
|
|
12
|
+
|
|
13
|
+
const response = await fetch(url, {
|
|
14
|
+
method: 'POST',
|
|
15
|
+
headers: { 'Content-Type': 'application/json' },
|
|
16
|
+
body: JSON.stringify({ topic, title, message, actions }),
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
if (!response.ok) {
|
|
20
|
+
throw new Error(`ntfy notification failed: HTTP ${response.status}`);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
return response;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Subscribe to the response topic via SSE and wait for a matching requestId.
|
|
28
|
+
*
|
|
29
|
+
* @param {{ server: string, topic: string, requestId: string, timeout: number }} params
|
|
30
|
+
* @returns {Promise<{ approved: boolean }>}
|
|
31
|
+
*/
|
|
32
|
+
export async function waitForResponse({ server, topic, requestId, timeout }) {
|
|
33
|
+
const baseUrl = server.replace(/\/+$/, '');
|
|
34
|
+
const url = `${baseUrl}/${topic}-response/json`;
|
|
35
|
+
|
|
36
|
+
const controller = new AbortController();
|
|
37
|
+
|
|
38
|
+
/** @type {ReturnType<typeof setTimeout> | undefined} */
|
|
39
|
+
let timer;
|
|
40
|
+
|
|
41
|
+
try {
|
|
42
|
+
const response = await fetch(url, { signal: controller.signal });
|
|
43
|
+
const reader = response.body.getReader();
|
|
44
|
+
const decoder = new TextDecoder();
|
|
45
|
+
let buffer = '';
|
|
46
|
+
|
|
47
|
+
// Listen to abort so we can cancel the reader even when the mock stream
|
|
48
|
+
// never closes (the real fetch would propagate the signal, but mocks may not).
|
|
49
|
+
const onAbort = () => reader.cancel();
|
|
50
|
+
controller.signal.addEventListener('abort', onAbort);
|
|
51
|
+
|
|
52
|
+
// Start the timeout AFTER fetch resolves so we measure waiting time only.
|
|
53
|
+
timer = setTimeout(() => controller.abort(), timeout);
|
|
54
|
+
|
|
55
|
+
try {
|
|
56
|
+
while (true) {
|
|
57
|
+
const { done, value } = await reader.read();
|
|
58
|
+
if (done) break;
|
|
59
|
+
|
|
60
|
+
buffer += decoder.decode(value, { stream: true });
|
|
61
|
+
const lines = buffer.split('\n');
|
|
62
|
+
buffer = lines.pop();
|
|
63
|
+
|
|
64
|
+
for (const line of lines) {
|
|
65
|
+
if (!line.trim()) continue;
|
|
66
|
+
try {
|
|
67
|
+
const event = JSON.parse(line);
|
|
68
|
+
const parsed = JSON.parse(event.message);
|
|
69
|
+
if (parsed.requestId === requestId) {
|
|
70
|
+
clearTimeout(timer);
|
|
71
|
+
controller.signal.removeEventListener('abort', onAbort);
|
|
72
|
+
return { approved: parsed.approved };
|
|
73
|
+
}
|
|
74
|
+
} catch {
|
|
75
|
+
// skip non-JSON lines
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
} finally {
|
|
80
|
+
controller.signal.removeEventListener('abort', onAbort);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
clearTimeout(timer);
|
|
84
|
+
return { approved: false };
|
|
85
|
+
} catch (err) {
|
|
86
|
+
if (timer !== undefined) clearTimeout(timer);
|
|
87
|
+
if (err?.name !== "AbortError") {
|
|
88
|
+
console.error("[claude-remote-approver] waitForResponse error:", err);
|
|
89
|
+
}
|
|
90
|
+
return { approved: false };
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Format tool information for display in the notification.
|
|
96
|
+
*
|
|
97
|
+
* @param {{ hookName: string, toolName: string, toolInput: Record<string, unknown> }} params
|
|
98
|
+
* @returns {{ title: string, message: string }}
|
|
99
|
+
*/
|
|
100
|
+
export function formatToolInfo({ hookName, toolName, toolInput }) {
|
|
101
|
+
const title = `Claude Code: ${toolName}`;
|
|
102
|
+
let message;
|
|
103
|
+
|
|
104
|
+
switch (toolName) {
|
|
105
|
+
case 'Bash':
|
|
106
|
+
message = toolInput?.command ?? JSON.stringify(toolInput);
|
|
107
|
+
break;
|
|
108
|
+
case 'Read':
|
|
109
|
+
case 'Write':
|
|
110
|
+
case 'Edit':
|
|
111
|
+
message = toolInput?.file_path ?? JSON.stringify(toolInput);
|
|
112
|
+
break;
|
|
113
|
+
default:
|
|
114
|
+
message = JSON.stringify(toolInput);
|
|
115
|
+
break;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
return { title, message };
|
|
119
|
+
}
|
package/src/setup.mjs
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Returns the hook command string: `node <absolute_path_to_src/hook.mjs>`
|
|
6
|
+
*/
|
|
7
|
+
export function getHookCommand() {
|
|
8
|
+
const hookPath = path.resolve(import.meta.dirname, "hook.mjs");
|
|
9
|
+
return `node "${hookPath}"`;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Registers the PermissionRequest hook in Claude's settings.json.
|
|
14
|
+
* Creates the file if it does not exist. Preserves all existing settings and hooks.
|
|
15
|
+
*/
|
|
16
|
+
export function registerHook(settingsPath, hookCommand) {
|
|
17
|
+
let settings = {};
|
|
18
|
+
|
|
19
|
+
try {
|
|
20
|
+
const raw = fs.readFileSync(settingsPath, "utf-8");
|
|
21
|
+
settings = JSON.parse(raw);
|
|
22
|
+
} catch (err) {
|
|
23
|
+
if (err.code !== "ENOENT") {
|
|
24
|
+
throw err;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
if (!settings.hooks) {
|
|
29
|
+
settings.hooks = {};
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
if (!Array.isArray(settings.hooks.PermissionRequest)) {
|
|
33
|
+
settings.hooks.PermissionRequest = [];
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const existingIndex = settings.hooks.PermissionRequest.findIndex(
|
|
37
|
+
(h) => h.command && h.command.includes("claude-remote-approver")
|
|
38
|
+
);
|
|
39
|
+
|
|
40
|
+
const hookEntry = { type: "command", command: hookCommand };
|
|
41
|
+
|
|
42
|
+
if (existingIndex >= 0) {
|
|
43
|
+
settings.hooks.PermissionRequest[existingIndex] = hookEntry;
|
|
44
|
+
} else {
|
|
45
|
+
settings.hooks.PermissionRequest.push(hookEntry);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2));
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Runs the full setup flow:
|
|
53
|
+
* 1. Generate a topic
|
|
54
|
+
* 2. Build and save config
|
|
55
|
+
* 3. Register the hook in settings.json
|
|
56
|
+
* 4. Return { topic, configPath, settingsPath }
|
|
57
|
+
*/
|
|
58
|
+
export async function runSetup({
|
|
59
|
+
configPath,
|
|
60
|
+
settingsPath,
|
|
61
|
+
generateTopic,
|
|
62
|
+
saveConfig,
|
|
63
|
+
loadConfig,
|
|
64
|
+
}) {
|
|
65
|
+
const topic = generateTopic();
|
|
66
|
+
|
|
67
|
+
const config = loadConfig(configPath);
|
|
68
|
+
config.topic = topic;
|
|
69
|
+
saveConfig(config, configPath);
|
|
70
|
+
|
|
71
|
+
const hookCommand = getHookCommand();
|
|
72
|
+
registerHook(settingsPath, hookCommand);
|
|
73
|
+
|
|
74
|
+
return { topic, configPath, settingsPath };
|
|
75
|
+
}
|