nofax 0.2.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 +351 -0
- package/SECURITY.md +144 -0
- package/bin/nofax.mjs +4 -0
- package/package.json +56 -0
- package/src/adapters/claude.mjs +47 -0
- package/src/adapters/codex.mjs +47 -0
- package/src/adapters/gemini.mjs +57 -0
- package/src/cli.mjs +191 -0
- package/src/config.mjs +91 -0
- package/src/index.mjs +8 -0
- package/src/mcp-server.mjs +141 -0
- package/src/mcp-tools.mjs +240 -0
- package/src/ntfy.mjs +259 -0
- package/src/protocol.mjs +108 -0
- package/src/requests.mjs +120 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Tomi Šeregi
|
|
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,351 @@
|
|
|
1
|
+
# Nofax
|
|
2
|
+
|
|
3
|
+
[](https://github.com/AKzar1el/nofax/actions/workflows/ci.yml)
|
|
4
|
+
[](LICENSE)
|
|
5
|
+
[](package.json)
|
|
6
|
+
[](https://modelcontextprotocol.io/)
|
|
7
|
+
|
|
8
|
+
**Human-in-the-loop approvals and notifications for AI coding agents, without running a Nofax SaaS.**
|
|
9
|
+
|
|
10
|
+
Nofax is a small open-source bridge between an agent and a human. Local mode can pause an AI workflow, notify your phone, and return an explicit decision. An optional self-deployed Cloudflare Worker exposes a deliberately narrower remote MCP surface for one-way notifications and safe request inspection.
|
|
11
|
+
|
|
12
|
+
No Nofax account. No paid model API. No inbound port on your machine. MIT licensed.
|
|
13
|
+
|
|
14
|
+
> **Current status:** local Nofax is `0.2.0`. The optional Cloudflare Worker is the upcoming `0.3.0` remote surface and is developed alongside the local package.
|
|
15
|
+
|
|
16
|
+
## Why Nofax
|
|
17
|
+
|
|
18
|
+
Agent workflows increasingly need a clean answer to one question: **when automation reaches a human decision boundary, how does it ask without pretending that silence means approval?**
|
|
19
|
+
|
|
20
|
+
Nofax keeps that boundary explicit:
|
|
21
|
+
|
|
22
|
+
- pending is never approval;
|
|
23
|
+
- timeout and transport failure fail closed;
|
|
24
|
+
- the first accepted terminal response wins;
|
|
25
|
+
- agent-specific hook schemas stay isolated in adapters;
|
|
26
|
+
- remote access is intentionally narrower than local access;
|
|
27
|
+
- Nofax does not grant authority the calling agent did not already have.
|
|
28
|
+
|
|
29
|
+
## Two operating modes
|
|
30
|
+
|
|
31
|
+
| Capability | Local Nofax `0.2` | Remote Worker `0.3` |
|
|
32
|
+
| --- | --- | --- |
|
|
33
|
+
| Transport | stdio / CLI hooks | MCP Streamable HTTP |
|
|
34
|
+
| One-way notification | Yes | Yes |
|
|
35
|
+
| Allow / Deny | Yes | No |
|
|
36
|
+
| Explicit choices | Yes | No |
|
|
37
|
+
| Free-text refinement | Yes | No |
|
|
38
|
+
| Wait for human response | Yes | No |
|
|
39
|
+
| Read request metadata | Yes | Yes |
|
|
40
|
+
| Durable state | Local files | Existing SQLite Durable Object rows |
|
|
41
|
+
| Hosted by Nofax | No | No — self-deployed Worker |
|
|
42
|
+
| Remote authentication | Local process boundary | Private bearer key |
|
|
43
|
+
|
|
44
|
+
The remote Worker is **not** a hosted remote-approval service. It can send an informational notification and inspect existing request state, but it has no approval callback, choice, refinement, wait, webhook, or arbitrary remote-write endpoint.
|
|
45
|
+
|
|
46
|
+
## Quick start
|
|
47
|
+
|
|
48
|
+
### 1. Install
|
|
49
|
+
|
|
50
|
+
Until a registry release is published:
|
|
51
|
+
|
|
52
|
+
```bash
|
|
53
|
+
npm install -g https://github.com/AKzar1el/nofax.git
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
Requires Node.js 20 or newer.
|
|
57
|
+
|
|
58
|
+
### 2. Initialize
|
|
59
|
+
|
|
60
|
+
```bash
|
|
61
|
+
nofax init
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
Nofax creates `~/.nofax/config.json` and generates a high-entropy notification topic. With the default transport, subscribe to the displayed topic in the ntfy mobile app.
|
|
65
|
+
|
|
66
|
+
### 3. Test
|
|
67
|
+
|
|
68
|
+
```bash
|
|
69
|
+
nofax test
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
### 4. Use it
|
|
73
|
+
|
|
74
|
+
```bash
|
|
75
|
+
nofax notify --title "Build finished" "All tests passed"
|
|
76
|
+
nofax approve --title "Deploy?" "Release 1.4.0 is ready"
|
|
77
|
+
nofax refine --title "Refine draft" "Tell me what to change"
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
An approval resolves to stable terminal JSON:
|
|
81
|
+
|
|
82
|
+
```json
|
|
83
|
+
{"decision":"allow"}
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
or:
|
|
87
|
+
|
|
88
|
+
```json
|
|
89
|
+
{"decision":"deny"}
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
If the request is still pending, times out, disconnects, or hits a transport error, Nofax never converts that condition into approval.
|
|
93
|
+
|
|
94
|
+
## MCP
|
|
95
|
+
|
|
96
|
+
Start the local stdio MCP server:
|
|
97
|
+
|
|
98
|
+
```bash
|
|
99
|
+
nofax mcp
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
Local MCP exposes:
|
|
103
|
+
|
|
104
|
+
- `nofax_notify`
|
|
105
|
+
- `nofax_request_approval`
|
|
106
|
+
- `nofax_request_choice`
|
|
107
|
+
- `nofax_request_refinement`
|
|
108
|
+
- `nofax_wait_for_response`
|
|
109
|
+
- `nofax_get_request`
|
|
110
|
+
- `nofax_list_pending`
|
|
111
|
+
|
|
112
|
+
Interactive requests return a durable request ID. `nofax_wait_for_response` performs a bounded wait; callers must repeat the wait while the request remains pending rather than infer approval.
|
|
113
|
+
|
|
114
|
+
## Agent integrations
|
|
115
|
+
|
|
116
|
+
### Claude Code
|
|
117
|
+
|
|
118
|
+
Use Nofax as a local `PermissionRequest` hook in `~/.claude/settings.json`:
|
|
119
|
+
|
|
120
|
+
```json
|
|
121
|
+
{
|
|
122
|
+
"hooks": {
|
|
123
|
+
"PermissionRequest": [
|
|
124
|
+
{
|
|
125
|
+
"matcher": ".*",
|
|
126
|
+
"hooks": [
|
|
127
|
+
{
|
|
128
|
+
"type": "command",
|
|
129
|
+
"command": "nofax hook claude"
|
|
130
|
+
}
|
|
131
|
+
]
|
|
132
|
+
}
|
|
133
|
+
]
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
### Codex
|
|
139
|
+
|
|
140
|
+
Codex hooks are enabled by default. Configure `~/.codex/hooks.json`:
|
|
141
|
+
|
|
142
|
+
```json
|
|
143
|
+
{
|
|
144
|
+
"hooks": {
|
|
145
|
+
"PermissionRequest": [
|
|
146
|
+
{
|
|
147
|
+
"matcher": ".*",
|
|
148
|
+
"hooks": [
|
|
149
|
+
{
|
|
150
|
+
"type": "command",
|
|
151
|
+
"command": "nofax hook codex",
|
|
152
|
+
"statusMessage": "Waiting for Nofax approval"
|
|
153
|
+
}
|
|
154
|
+
]
|
|
155
|
+
}
|
|
156
|
+
]
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
Restart Codex, run `/hooks`, and review/trust the exact Nofax hook definition before relying on it. Codex skips non-managed hooks until they are trusted, and a changed hook definition must be reviewed again. If an administrator or local policy has explicitly disabled hooks, re-enable them with `[features] hooks = true` in `~/.codex/config.toml`.
|
|
162
|
+
|
|
163
|
+
### Gemini CLI
|
|
164
|
+
|
|
165
|
+
Current Gemini CLI builds expose a synchronous `BeforeTool` hook that can allow or deny a tool call. Route selected tools through Nofax in `~/.gemini/settings.json`:
|
|
166
|
+
|
|
167
|
+
```json
|
|
168
|
+
{
|
|
169
|
+
"hooks": {
|
|
170
|
+
"BeforeTool": [
|
|
171
|
+
{
|
|
172
|
+
"matcher": "run_shell_command|write_file|replace",
|
|
173
|
+
"hooks": [
|
|
174
|
+
{
|
|
175
|
+
"name": "nofax-approval",
|
|
176
|
+
"type": "command",
|
|
177
|
+
"command": "nofax hook gemini",
|
|
178
|
+
"timeout": 305000
|
|
179
|
+
}
|
|
180
|
+
]
|
|
181
|
+
}
|
|
182
|
+
],
|
|
183
|
+
"Notification": [
|
|
184
|
+
{
|
|
185
|
+
"matcher": "ToolPermission",
|
|
186
|
+
"hooks": [
|
|
187
|
+
{
|
|
188
|
+
"name": "nofax-notification",
|
|
189
|
+
"type": "command",
|
|
190
|
+
"command": "nofax hook gemini"
|
|
191
|
+
}
|
|
192
|
+
]
|
|
193
|
+
}
|
|
194
|
+
]
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
`BeforeTool` waits for an explicit Nofax Allow/Deny result. A Nofax timeout or transport failure emits valid no-decision JSON and leaves Gemini CLI's own policy/confirmation flow in control rather than converting failure into approval. The `Notification` hook remains advisory and is forwarded only as a phone notification.
|
|
200
|
+
|
|
201
|
+
Adjust the matcher to the tools you want Nofax to gate. Keep the hook timeout longer than Nofax's configured approval timeout (`timeoutSeconds`, 300 seconds by default).
|
|
202
|
+
|
|
203
|
+
## Optional remote Cloudflare Worker
|
|
204
|
+
|
|
205
|
+
The `worker/` package provides a private, self-deployed MCP endpoint:
|
|
206
|
+
|
|
207
|
+
```text
|
|
208
|
+
remote MCP client
|
|
209
|
+
|
|
|
210
|
+
| authenticated Streamable HTTP
|
|
211
|
+
v
|
|
212
|
+
Cloudflare Worker
|
|
213
|
+
|
|
|
214
|
+
+--> nofax_notify ------> ntfy ------> phone
|
|
215
|
+
|
|
|
216
|
+
+--> SQLite Durable Object
|
|
217
|
+
|
|
|
218
|
+
+--> get request metadata
|
|
219
|
+
+--> list pending requests
|
|
220
|
+
```
|
|
221
|
+
|
|
222
|
+
It exposes exactly three tools:
|
|
223
|
+
|
|
224
|
+
- `nofax_notify` — one-way notification only;
|
|
225
|
+
- `nofax_get_request` — read one safe request projection;
|
|
226
|
+
- `nofax_list_pending` — read unresolved, unexpired request projections.
|
|
227
|
+
|
|
228
|
+
Deploy from `worker/`:
|
|
229
|
+
|
|
230
|
+
```bash
|
|
231
|
+
npm ci
|
|
232
|
+
npx wrangler login
|
|
233
|
+
npx wrangler secret put NOFAX_REMOTE_KEY
|
|
234
|
+
npx wrangler secret put NTFY_TOPIC
|
|
235
|
+
npm run check
|
|
236
|
+
npm run deploy
|
|
237
|
+
```
|
|
238
|
+
|
|
239
|
+
Preferred MCP connection:
|
|
240
|
+
|
|
241
|
+
```text
|
|
242
|
+
https://<worker>.workers.dev/mcp
|
|
243
|
+
Authorization: Bearer <NOFAX_REMOTE_KEY>
|
|
244
|
+
```
|
|
245
|
+
|
|
246
|
+
Clients that cannot attach a static authorization header can use the compatibility capability path:
|
|
247
|
+
|
|
248
|
+
```text
|
|
249
|
+
https://<worker>.workers.dev/mcp/<NOFAX_REMOTE_KEY>
|
|
250
|
+
```
|
|
251
|
+
|
|
252
|
+
Treat the complete capability URL like a password.
|
|
253
|
+
|
|
254
|
+
See [`docs/remote-mcp.md`](docs/remote-mcp.md) for deployment, threat boundaries, and qualification details.
|
|
255
|
+
|
|
256
|
+
### Important: public ntfy + serverless egress
|
|
257
|
+
|
|
258
|
+
The default public `ntfy.sh` service applies publisher quotas. Serverless platforms such as Cloudflare Workers may use shared outbound IP space, so a Worker can receive an ntfy `42908` daily-quota response even when that individual Worker has sent very little traffic. That limit is imposed by ntfy, not by the Cloudflare Workers request quota.
|
|
259
|
+
|
|
260
|
+
For reliability-sensitive deployments, use a notification provider whose quota is tied to your own authenticated account/identity, or operate a trusted self-hosted transport. Do not build a critical workflow around anonymous public-topic quota assumptions.
|
|
261
|
+
|
|
262
|
+
## Security model
|
|
263
|
+
|
|
264
|
+
Nofax is a transport and human-interaction component, **not an authorization policy engine**.
|
|
265
|
+
|
|
266
|
+
Local mode:
|
|
267
|
+
|
|
268
|
+
- pending, timeout, disconnect, malformed state, and network failure never mean approval;
|
|
269
|
+
- the first valid terminal response wins;
|
|
270
|
+
- notification topics and one-time response topics are capabilities;
|
|
271
|
+
- public ntfy is not end-to-end encrypted from the provider;
|
|
272
|
+
- redaction is best-effort and cannot reliably identify secrets embedded in arbitrary free-form text.
|
|
273
|
+
|
|
274
|
+
Remote mode:
|
|
275
|
+
|
|
276
|
+
- only explicit `nofax_notify` performs an external messaging side effect;
|
|
277
|
+
- request-inspection operations are read-only and do not perform hidden cleanup writes;
|
|
278
|
+
- remote approval, callback, webhook, refinement, choice, and wait surfaces are absent;
|
|
279
|
+
- `NOFAX_REMOTE_KEY` is a bearer credential;
|
|
280
|
+
- remote projections omit callback capabilities, prompt/message text, and internal allowed-decision lists.
|
|
281
|
+
|
|
282
|
+
Read [`SECURITY.md`](SECURITY.md) before using Nofax with sensitive information.
|
|
283
|
+
|
|
284
|
+
## Configuration
|
|
285
|
+
|
|
286
|
+
Default local config lives at `~/.nofax/config.json`:
|
|
287
|
+
|
|
288
|
+
```json
|
|
289
|
+
{
|
|
290
|
+
"version": 1,
|
|
291
|
+
"server": "https://ntfy.sh",
|
|
292
|
+
"topic": "nofax_<random>",
|
|
293
|
+
"timeoutSeconds": 300
|
|
294
|
+
}
|
|
295
|
+
```
|
|
296
|
+
|
|
297
|
+
Override the home directory with `NOFAX_HOME`:
|
|
298
|
+
|
|
299
|
+
```bash
|
|
300
|
+
NOFAX_HOME=/path/to/nofax-home nofax config
|
|
301
|
+
```
|
|
302
|
+
|
|
303
|
+
Use another ntfy-compatible server with:
|
|
304
|
+
|
|
305
|
+
```bash
|
|
306
|
+
nofax init --server https://ntfy.example.com --force
|
|
307
|
+
```
|
|
308
|
+
|
|
309
|
+
## Development
|
|
310
|
+
|
|
311
|
+
Local package:
|
|
312
|
+
|
|
313
|
+
```bash
|
|
314
|
+
npm ci
|
|
315
|
+
npm run check
|
|
316
|
+
npm test
|
|
317
|
+
npm pack --dry-run
|
|
318
|
+
```
|
|
319
|
+
|
|
320
|
+
Remote Worker:
|
|
321
|
+
|
|
322
|
+
```bash
|
|
323
|
+
cd worker
|
|
324
|
+
npm ci
|
|
325
|
+
npm run check
|
|
326
|
+
```
|
|
327
|
+
|
|
328
|
+
CI qualifies Node.js 20, 22, and 24 for the local package. The Worker gate runs TypeScript, Vitest, a production-dependency audit, and a Wrangler deployment dry-run.
|
|
329
|
+
|
|
330
|
+
## Project docs
|
|
331
|
+
|
|
332
|
+
- [`docs/architecture.md`](docs/architecture.md) — trust boundaries and data flow
|
|
333
|
+
- [`docs/remote-mcp.md`](docs/remote-mcp.md) — remote Worker deployment and qualification
|
|
334
|
+
- [`SECURITY.md`](SECURITY.md) — security assumptions and vulnerability reporting
|
|
335
|
+
- [`CONTRIBUTING.md`](CONTRIBUTING.md) — contribution and test expectations
|
|
336
|
+
- [`CHANGELOG.md`](CHANGELOG.md) — release history
|
|
337
|
+
|
|
338
|
+
## Non-goals
|
|
339
|
+
|
|
340
|
+
Nofax deliberately does not provide:
|
|
341
|
+
|
|
342
|
+
- a Nofax-operated approval SaaS;
|
|
343
|
+
- a paid model API dependency;
|
|
344
|
+
- persistent `always approve` policy;
|
|
345
|
+
- an arbitrary remote shell endpoint;
|
|
346
|
+
- a public multi-user Worker behind one shared deployment key;
|
|
347
|
+
- a claim that MCP annotations themselves are a security boundary.
|
|
348
|
+
|
|
349
|
+
## License
|
|
350
|
+
|
|
351
|
+
MIT © Tomi Šeregi. See [`LICENSE`](LICENSE).
|
package/SECURITY.md
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
# Security Policy
|
|
2
|
+
|
|
3
|
+
## Supported versions
|
|
4
|
+
|
|
5
|
+
Nofax is pre-1.0 software. Security fixes are applied to the latest release on the default branch.
|
|
6
|
+
|
|
7
|
+
## Reporting a vulnerability
|
|
8
|
+
|
|
9
|
+
Do not publish credentials, exploit details, private ntfy topics, one-time response URLs, remote MCP capability URLs, or sensitive hook/MCP payloads in a public issue.
|
|
10
|
+
|
|
11
|
+
If GitHub private vulnerability reporting is enabled for this repository, use it. Otherwise, open a minimal public issue asking for a private maintainer contact channel without including sensitive details.
|
|
12
|
+
|
|
13
|
+
## Local Nofax security
|
|
14
|
+
|
|
15
|
+
### Public ntfy is not end-to-end encryption
|
|
16
|
+
|
|
17
|
+
With the default `https://ntfy.sh` configuration, notification content transits and may be cached by the ntfy service. A random topic name reduces unauthorized discovery but does not encrypt content from the service operator.
|
|
18
|
+
|
|
19
|
+
Use a trusted authenticated/self-hosted ntfy server for sensitive source code, production operations, credentials, regulated data, or confidential prompts.
|
|
20
|
+
|
|
21
|
+
### Topics and one-time response URLs are capabilities
|
|
22
|
+
|
|
23
|
+
On anonymous ntfy servers, knowledge of a topic can be sufficient to subscribe or publish. Treat the local ntfy topic as a bearer secret.
|
|
24
|
+
|
|
25
|
+
Local Nofax generates fresh one-time response topics for interactive requests. Do not log, share, bookmark, or persist those callback URLs outside the state Nofax itself requires.
|
|
26
|
+
|
|
27
|
+
### Durable local request state
|
|
28
|
+
|
|
29
|
+
Local MCP human-response requests are stored under `~/.nofax/requests/` so an MCP/client restart does not erase an unresolved human decision gate.
|
|
30
|
+
|
|
31
|
+
Nofax writes these files with user-only permissions where supported. MCP-facing request projections deliberately omit the secret response topic. Protect the Nofax home directory like other local application state.
|
|
32
|
+
|
|
33
|
+
### Pending never means approved
|
|
34
|
+
|
|
35
|
+
A local interactive request remains pending until a matching terminal response is accepted. Passage of time, timeout, tool failure, network failure, or client disconnect never means approval.
|
|
36
|
+
|
|
37
|
+
`nofax_wait_for_response` uses bounded long-polls. If it returns pending, the caller must wait again rather than continue the guarded action.
|
|
38
|
+
|
|
39
|
+
### First terminal response wins
|
|
40
|
+
|
|
41
|
+
Local durable requests are single-use. Once a valid terminal response is accepted, later responses cannot intentionally replace it.
|
|
42
|
+
|
|
43
|
+
### Native hook failures never become approval
|
|
44
|
+
|
|
45
|
+
The Claude Code and Codex adapters fail closed. If the local transport times out, returns malformed data, or encounters a network/polling error, Nofax does not synthesize an Allow decision.
|
|
46
|
+
|
|
47
|
+
### Redaction is best-effort
|
|
48
|
+
|
|
49
|
+
Nofax redacts values under common secret-bearing object keys and bounds serialized payloads. It cannot reliably detect a credential embedded in arbitrary free-form command text. Treat all notification content accordingly.
|
|
50
|
+
|
|
51
|
+
## Remote Worker security
|
|
52
|
+
|
|
53
|
+
The optional Cloudflare Worker in v0.3 is a **one-way notification plus inspection remote MCP endpoint**.
|
|
54
|
+
|
|
55
|
+
### Remote side effects are structurally bounded
|
|
56
|
+
|
|
57
|
+
MCP tool annotations are descriptive metadata, not the security boundary.
|
|
58
|
+
|
|
59
|
+
The remote Worker bounds side effects in code:
|
|
60
|
+
|
|
61
|
+
- only `nofax_notify`, `nofax_get_request`, and `nofax_list_pending` are registered as MCP tools;
|
|
62
|
+
- the remote handler object exposes one-way notification plus two read operations;
|
|
63
|
+
- there is no remote approval, choice, refinement, wait, callback, or webhook handler;
|
|
64
|
+
- public Worker routing is limited to health and authenticated MCP paths;
|
|
65
|
+
- `/telegram/webhook` and `/r/*` are not routes and return 404;
|
|
66
|
+
- pending-list reads filter expired rows without deleting them or performing hidden cleanup writes.
|
|
67
|
+
|
|
68
|
+
The two inspection tools are annotated with `readOnlyHint: true`, `destructiveHint: false`, `idempotentHint: true`, and `openWorldHint: false`. `nofax_notify` is accurately marked side-effecting/non-idempotent/open-world and non-destructive.
|
|
69
|
+
|
|
70
|
+
### Remote response minimization
|
|
71
|
+
|
|
72
|
+
Remote MCP responses deliberately omit capability-bearing or unnecessary fields. Public request projections do not expose:
|
|
73
|
+
|
|
74
|
+
- callback hashes;
|
|
75
|
+
- callback tokens/URLs;
|
|
76
|
+
- original request title;
|
|
77
|
+
- original request message/prompt material;
|
|
78
|
+
- internal allowed-decision lists.
|
|
79
|
+
|
|
80
|
+
Terminal state may include the stored terminal decision and historical refinement text when present in existing durable state.
|
|
81
|
+
|
|
82
|
+
### Remote MCP key is a bearer secret
|
|
83
|
+
|
|
84
|
+
The Worker is a private single-user deployment protected by `NOFAX_REMOTE_KEY`.
|
|
85
|
+
|
|
86
|
+
Preferred clients send:
|
|
87
|
+
|
|
88
|
+
```text
|
|
89
|
+
Authorization: Bearer <NOFAX_REMOTE_KEY>
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
Clients that cannot attach a static header may use:
|
|
93
|
+
|
|
94
|
+
```text
|
|
95
|
+
/mcp/<NOFAX_REMOTE_KEY>
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
The complete capability URL is equivalent to a password and may leak through browser history, screenshots, copied configuration, proxies, or third-party logging. Prefer the Authorization header whenever the MCP host supports it.
|
|
99
|
+
|
|
100
|
+
Nofax uses equal-length constant-time credential comparison. After capability-path authentication, the Worker normalizes the request internally to `/mcp` before MCP protocol handling.
|
|
101
|
+
|
|
102
|
+
Rotate `NOFAX_REMOTE_KEY` immediately if it is exposed.
|
|
103
|
+
|
|
104
|
+
A single deployment-wide key is not sufficient for a shared/public multi-user service. Use a delegated authentication and authorization design before operating a multi-user deployment.
|
|
105
|
+
|
|
106
|
+
### Durable Object compatibility
|
|
107
|
+
|
|
108
|
+
The v0.3 Worker preserves the existing SQLite request schema so upgrading from experimental pre-read-only Worker builds does not require destructive storage migration.
|
|
109
|
+
|
|
110
|
+
Only request-inspection methods are reachable for Durable Object state. Existing legacy rows may therefore be inspected after upgrade, but the Worker cannot create, resolve, or delete them through MCP or HTTP routes. The separate `nofax_notify` method can publish an explicitly requested one-way notification and does not mutate request state.
|
|
111
|
+
|
|
112
|
+
### Cloudflare is the remote trust boundary
|
|
113
|
+
|
|
114
|
+
Remote mode adds Cloudflare as an infrastructure boundary. For notification calls, ntfy is an additional provider boundary and receives the bounded notification title/message plus the configured topic identifier.
|
|
115
|
+
|
|
116
|
+
The Worker does not automatically forward durable request records to ntfy. Only explicit `nofax_notify` content is published; Telegram, WhatsApp, SMS, and human-response callbacks remain absent remotely.
|
|
117
|
+
|
|
118
|
+
Do not expose the deployment key in source, Wrangler vars, `.env`, `.dev.vars`, CI logs, PR text, screenshots, or issue reports. Use Wrangler secrets for production values and keep local secret files untracked.
|
|
119
|
+
|
|
120
|
+
### Hosted ntfy quotas are an external availability boundary
|
|
121
|
+
|
|
122
|
+
The public `ntfy.sh` service applies its own publisher limits independently of Cloudflare Workers quotas. Serverless egress may be shared between unrelated workloads, so a Cloudflare Worker can receive an ntfy `42908` daily-quota response even when that individual Worker has published little traffic.
|
|
123
|
+
|
|
124
|
+
Treat public ntfy availability and quota policy as an external dependency. Reliability-sensitive deployments should use a transport with account-scoped quota/identity or a trusted self-hosted service rather than assuming anonymous public-topic capacity.
|
|
125
|
+
|
|
126
|
+
## Nofax is not a policy engine
|
|
127
|
+
|
|
128
|
+
Local Nofax answers approval requests that an upstream agent or workflow explicitly delegates to it. It does not decide which operations should require approval and must not be used to bypass an agent's deny rules, sandbox, or existing authorization boundaries.
|
|
129
|
+
|
|
130
|
+
An `allow` result permits the caller to continue only within authority it already possessed.
|
|
131
|
+
|
|
132
|
+
The remote Worker does not grant authority. Its only mutation is sending an informational notification; request-state operations remain inspection-only.
|
|
133
|
+
|
|
134
|
+
## MCP transport notes
|
|
135
|
+
|
|
136
|
+
### Local stdio
|
|
137
|
+
|
|
138
|
+
`nofax mcp` is a local stdio server. Stdout is reserved for MCP protocol traffic. Do not wrap it with tooling that injects banners or diagnostics into stdout.
|
|
139
|
+
|
|
140
|
+
### Remote Streamable HTTP
|
|
141
|
+
|
|
142
|
+
The optional Worker uses authenticated Streamable HTTP for MCP. The only unauthenticated functional route is the secret-free `/healthz` health check.
|
|
143
|
+
|
|
144
|
+
See [`docs/remote-mcp.md`](docs/remote-mcp.md) for the remote architecture and qualification checklist.
|
package/bin/nofax.mjs
ADDED
package/package.json
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "nofax",
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"mcpName": "io.github.AKzar1el/nofax",
|
|
5
|
+
"description": "Human-in-the-loop approvals and notifications for AI coding agents via CLI, MCP, and agent hooks.",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"bin": {
|
|
8
|
+
"nofax": "./bin/nofax.mjs"
|
|
9
|
+
},
|
|
10
|
+
"exports": {
|
|
11
|
+
".": "./src/index.mjs",
|
|
12
|
+
"./mcp": "./src/mcp-server.mjs"
|
|
13
|
+
},
|
|
14
|
+
"files": [
|
|
15
|
+
"bin/",
|
|
16
|
+
"src/",
|
|
17
|
+
"README.md",
|
|
18
|
+
"LICENSE",
|
|
19
|
+
"SECURITY.md"
|
|
20
|
+
],
|
|
21
|
+
"scripts": {
|
|
22
|
+
"test": "node --test test/*.test.mjs",
|
|
23
|
+
"check": "node --check bin/nofax.mjs && node --check src/cli.mjs && node --check src/config.mjs && node --check src/protocol.mjs && node --check src/ntfy.mjs && node --check src/requests.mjs && node --check src/mcp-tools.mjs && node --check src/mcp-server.mjs && node --check src/adapters/claude.mjs && node --check src/adapters/codex.mjs && node --check src/adapters/gemini.mjs"
|
|
24
|
+
},
|
|
25
|
+
"engines": {
|
|
26
|
+
"node": ">=20.0.0"
|
|
27
|
+
},
|
|
28
|
+
"dependencies": {
|
|
29
|
+
"@modelcontextprotocol/server": "2.0.0",
|
|
30
|
+
"zod": "4.4.3"
|
|
31
|
+
},
|
|
32
|
+
"keywords": [
|
|
33
|
+
"ai-agent",
|
|
34
|
+
"approval",
|
|
35
|
+
"claude-code",
|
|
36
|
+
"codex",
|
|
37
|
+
"gemini-cli",
|
|
38
|
+
"human-in-the-loop",
|
|
39
|
+
"mcp",
|
|
40
|
+
"model-context-protocol",
|
|
41
|
+
"ntfy",
|
|
42
|
+
"notifications",
|
|
43
|
+
"remote",
|
|
44
|
+
"developer-tools"
|
|
45
|
+
],
|
|
46
|
+
"license": "MIT",
|
|
47
|
+
"author": "Tomi Šeregi",
|
|
48
|
+
"repository": {
|
|
49
|
+
"type": "git",
|
|
50
|
+
"url": "git+https://github.com/AKzar1el/nofax.git"
|
|
51
|
+
},
|
|
52
|
+
"homepage": "https://github.com/AKzar1el/nofax#readme",
|
|
53
|
+
"bugs": {
|
|
54
|
+
"url": "https://github.com/AKzar1el/nofax/issues"
|
|
55
|
+
}
|
|
56
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { buildAgentSummary } from '../protocol.mjs';
|
|
2
|
+
import { requestApproval } from '../ntfy.mjs';
|
|
3
|
+
|
|
4
|
+
function assertClaudePermissionRequest(input) {
|
|
5
|
+
if (!input || input.hook_event_name !== 'PermissionRequest') throw new Error('NOFAX_CLAUDE_EVENT');
|
|
6
|
+
if (typeof input.tool_name !== 'string' || !input.tool_name) throw new Error('NOFAX_CLAUDE_TOOL');
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export async function handleClaudePermissionRequest(input, {
|
|
10
|
+
config,
|
|
11
|
+
requestApprovalImpl = requestApproval,
|
|
12
|
+
onError = () => {}
|
|
13
|
+
} = {}) {
|
|
14
|
+
assertClaudePermissionRequest(input);
|
|
15
|
+
try {
|
|
16
|
+
const result = await requestApprovalImpl({
|
|
17
|
+
config,
|
|
18
|
+
title: `Claude Code needs approval: ${input.tool_name}`,
|
|
19
|
+
message: buildAgentSummary({
|
|
20
|
+
source: 'Claude Code',
|
|
21
|
+
toolName: input.tool_name,
|
|
22
|
+
cwd: input.cwd,
|
|
23
|
+
toolInput: input.tool_input
|
|
24
|
+
})
|
|
25
|
+
});
|
|
26
|
+
if (result.decision === 'allow') {
|
|
27
|
+
return {
|
|
28
|
+
hookSpecificOutput: {
|
|
29
|
+
hookEventName: 'PermissionRequest',
|
|
30
|
+
decision: { behavior: 'allow' }
|
|
31
|
+
}
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
if (result.decision === 'deny') {
|
|
35
|
+
return {
|
|
36
|
+
hookSpecificOutput: {
|
|
37
|
+
hookEventName: 'PermissionRequest',
|
|
38
|
+
decision: { behavior: 'deny', message: 'Denied remotely via Nofax.' }
|
|
39
|
+
}
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
return null;
|
|
43
|
+
} catch (error) {
|
|
44
|
+
onError(error);
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { buildAgentSummary } from '../protocol.mjs';
|
|
2
|
+
import { requestApproval } from '../ntfy.mjs';
|
|
3
|
+
|
|
4
|
+
function assertCodexPermissionRequest(input) {
|
|
5
|
+
if (!input || input.hook_event_name !== 'PermissionRequest') throw new Error('NOFAX_CODEX_EVENT');
|
|
6
|
+
if (typeof input.tool_name !== 'string' || !input.tool_name) throw new Error('NOFAX_CODEX_TOOL');
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export async function handleCodexPermissionRequest(input, {
|
|
10
|
+
config,
|
|
11
|
+
requestApprovalImpl = requestApproval,
|
|
12
|
+
onError = () => {}
|
|
13
|
+
} = {}) {
|
|
14
|
+
assertCodexPermissionRequest(input);
|
|
15
|
+
try {
|
|
16
|
+
const result = await requestApprovalImpl({
|
|
17
|
+
config,
|
|
18
|
+
title: `Codex needs approval: ${input.tool_name}`,
|
|
19
|
+
message: buildAgentSummary({
|
|
20
|
+
source: 'Codex',
|
|
21
|
+
toolName: input.tool_name,
|
|
22
|
+
cwd: input.cwd,
|
|
23
|
+
toolInput: input.tool_input
|
|
24
|
+
})
|
|
25
|
+
});
|
|
26
|
+
if (result.decision === 'allow') {
|
|
27
|
+
return {
|
|
28
|
+
hookSpecificOutput: {
|
|
29
|
+
hookEventName: 'PermissionRequest',
|
|
30
|
+
decision: { behavior: 'allow' }
|
|
31
|
+
}
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
if (result.decision === 'deny') {
|
|
35
|
+
return {
|
|
36
|
+
hookSpecificOutput: {
|
|
37
|
+
hookEventName: 'PermissionRequest',
|
|
38
|
+
decision: { behavior: 'deny', message: 'Denied remotely via Nofax.' }
|
|
39
|
+
}
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
return null;
|
|
43
|
+
} catch (error) {
|
|
44
|
+
onError(error);
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
}
|