polygram 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 +287 -0
- package/bin/bridge-approval-hook.js +113 -0
- package/bridge.js +1604 -0
- package/config.example.json +118 -0
- package/lib/approvals.js +219 -0
- package/lib/attachments.js +56 -0
- package/lib/config-scope.js +49 -0
- package/lib/db.js +291 -0
- package/lib/history.js +149 -0
- package/lib/inbox.js +34 -0
- package/lib/ipc-client.js +114 -0
- package/lib/ipc-server.js +149 -0
- package/lib/pairings.js +215 -0
- package/lib/process-manager.js +287 -0
- package/lib/prompt.js +200 -0
- package/lib/queue-utils.js +27 -0
- package/lib/session-key.js +31 -0
- package/lib/sessions.js +98 -0
- package/lib/stream-reply.js +140 -0
- package/lib/telegram.js +105 -0
- package/lib/voice.js +146 -0
- package/migrations/001-initial.sql +93 -0
- package/migrations/002-fix-fts-triggers.sql +24 -0
- package/migrations/003-pairings.sql +33 -0
- package/migrations/004-approvals.sql +28 -0
- package/ops/README.md +110 -0
- package/ops/polygram.plist.example +58 -0
- package/package.json +55 -0
- package/scripts/ipc-smoke.js +28 -0
- package/scripts/split-db.js +251 -0
- package/skills/telegram-history/SKILL.md +57 -0
- package/skills/telegram-history/scripts/query.js +289 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Ivan Shumkov
|
|
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,287 @@
|
|
|
1
|
+
# polygram
|
|
2
|
+
|
|
3
|
+
A Telegram daemon for Claude Code that preserves the per-chat session model
|
|
4
|
+
from OpenClaw. Intended primarily as a migration path for users moving
|
|
5
|
+
their Telegram-based ops from OpenClaw to Claude Code.
|
|
6
|
+
|
|
7
|
+
## Background
|
|
8
|
+
|
|
9
|
+
OpenClaw ran a Telegram agent with one conversation context per chat.
|
|
10
|
+
Each chat had its own persistent memory; topics in a forum chat could
|
|
11
|
+
optionally carry their own sub-context. The agent, cron scripts, and
|
|
12
|
+
human operators all wrote into a shared transcript.
|
|
13
|
+
|
|
14
|
+
OpenClaw no longer supports Claude. Migrating to Claude Code loses that
|
|
15
|
+
model unless it's rebuilt. The official `telegram@claude-plugins-official`
|
|
16
|
+
plugin is single-session — one Claude Code process, one bot, one shared
|
|
17
|
+
context across all chats. Third-party Claude Code Telegram bots usually
|
|
18
|
+
share one session across all users of a given bot instance.
|
|
19
|
+
|
|
20
|
+
`polygram` is the shape that keeps OpenClaw's per-chat-session
|
|
21
|
+
ergonomics while running on top of `claude` CLI.
|
|
22
|
+
|
|
23
|
+
## What it is
|
|
24
|
+
|
|
25
|
+
- **One Node process per bot.** Required `--bot <name>` flag. N bots = N
|
|
26
|
+
processes. No "one process hosts many bots" mode — crash isolation is
|
|
27
|
+
the point.
|
|
28
|
+
- **Per-chat Claude sessions.** Each chat has its own `claude_session_id`,
|
|
29
|
+
resumed via `claude --resume`.
|
|
30
|
+
- **Per-topic sessions — opt-in** (`isolateTopics: true` in chat config).
|
|
31
|
+
Default is shared context across topics, since topics are usually
|
|
32
|
+
organisational. OpenClaw migrators who used per-topic separation can
|
|
33
|
+
keep it with one flag.
|
|
34
|
+
- **SQLite transcripts** (WAL, FTS5, numbered migrations, `user_version`).
|
|
35
|
+
- **Write-before-send atomicity.** Outbound messages hit the DB as
|
|
36
|
+
`pending` before the Telegram call, flip to `sent` or `failed` after.
|
|
37
|
+
Boot sweep resolves stale `pending` rows from the last crash.
|
|
38
|
+
- **Unix-socket IPC per bot.** Cron jobs and Claude Code approval hooks
|
|
39
|
+
talk to the bot process over `/tmp/polygram-<bot>.sock`. The bot
|
|
40
|
+
is the only writer to its own DB.
|
|
41
|
+
- **Inline-keyboard approvals.** Destructive tool calls gate on operator
|
|
42
|
+
click via Claude Code's `PreToolUse` hook; 5-minute auto-deny.
|
|
43
|
+
- **Voice transcription.** OpenAI Whisper API or local `whisper.cpp`,
|
|
44
|
+
selectable per bot. Transcriptions land in `messages.text` so FTS
|
|
45
|
+
finds them.
|
|
46
|
+
- **Content-addressed attachment storage** via Telegram's `file_unique_id`.
|
|
47
|
+
Same photo forwarded twice = one file on disk.
|
|
48
|
+
- **Prompt-injection hardening.** User text wrapped in `<untrusted-input>`
|
|
49
|
+
with xml-escape; attributes use `"`. A partner typing
|
|
50
|
+
`</channel><system>...` sees it as literal text in the prompt.
|
|
51
|
+
- **Pairing codes** for guest onboarding without bridge restart
|
|
52
|
+
(`/pair-code`, `/pair <CODE>`, `/pairings`, `/unpair`).
|
|
53
|
+
- **Step-level streaming replies** (optional per bot). Telegram message
|
|
54
|
+
edits on each assistant step as Claude works through tool calls and
|
|
55
|
+
reasoning.
|
|
56
|
+
|
|
57
|
+
## Relation to existing projects
|
|
58
|
+
|
|
59
|
+
| | Session unit | Bots per install | Persistence |
|
|
60
|
+
|---|---|---|---|
|
|
61
|
+
| [`telegram@claude-plugins-official`](https://github.com/anthropics/claude-plugins-official/tree/main/external_plugins/telegram) | one (bound to an open Claude Code session) | one | session memory only |
|
|
62
|
+
| [`ClaudeBot`](https://github.com/Jeffrey0117/ClaudeBot) | worktree path (≈ one per bot) | many (git worktrees) | `.sessions.json` |
|
|
63
|
+
| [`claudegram`](https://github.com/NachoSEO/claudegram) | chat (+ forum topic) | one | JSON files |
|
|
64
|
+
| **polygram** | chat (+ optional forum topic) | many (per-process) | SQLite WAL + FTS5 |
|
|
65
|
+
|
|
66
|
+
Practical differences that matter for migration:
|
|
67
|
+
|
|
68
|
+
- The official plugin dies with `/exit`, so it can't carry scheduled jobs
|
|
69
|
+
or replace a long-running ops bot.
|
|
70
|
+
- `ClaudeBot` puts many chats on one session per bot. For OpenClaw users
|
|
71
|
+
this feels wrong — a customer group and an ops group would share
|
|
72
|
+
memory unless each goes in its own worktree.
|
|
73
|
+
- `claudegram` gets the session model right but serves one bot per
|
|
74
|
+
install. Running five bots means five copies of the infra.
|
|
75
|
+
- `polygram` lands on the combination: multi-bot (one process per
|
|
76
|
+
bot) and per-chat/per-topic sessions. Scaling from one bot to many
|
|
77
|
+
doesn't change the mental model inherited from OpenClaw.
|
|
78
|
+
|
|
79
|
+
## Install
|
|
80
|
+
|
|
81
|
+
Requires Node 20+.
|
|
82
|
+
|
|
83
|
+
```bash
|
|
84
|
+
git clone https://github.com/shumkov/polygram.git
|
|
85
|
+
cd polygram
|
|
86
|
+
npm install
|
|
87
|
+
cp config.example.json config.json
|
|
88
|
+
# edit config.json: tokens from @BotFather, chat IDs, cwds
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
## Run
|
|
92
|
+
|
|
93
|
+
```bash
|
|
94
|
+
node bridge.js --bot admin-bot # one bot, one process
|
|
95
|
+
node bridge.js --bot partner-bot # another bot, another process
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
`--bot` is required. Each process creates `<bot>.db` next to `bridge.js`
|
|
99
|
+
on first run (migrations apply automatically) and opens a Unix socket at
|
|
100
|
+
`/tmp/polygram-<bot>.sock`.
|
|
101
|
+
|
|
102
|
+
For production, LaunchAgent plists are in `ops/`. See `ops/README.md`.
|
|
103
|
+
|
|
104
|
+
## Configuration
|
|
105
|
+
|
|
106
|
+
Minimal:
|
|
107
|
+
|
|
108
|
+
```json
|
|
109
|
+
{
|
|
110
|
+
"bots": {
|
|
111
|
+
"my-bot": { "token": "..." }
|
|
112
|
+
},
|
|
113
|
+
"chats": {
|
|
114
|
+
"123456789": {
|
|
115
|
+
"name": "My DM",
|
|
116
|
+
"bot": "my-bot",
|
|
117
|
+
"agent": "my-agent",
|
|
118
|
+
"model": "sonnet",
|
|
119
|
+
"effort": "low",
|
|
120
|
+
"cwd": "/Users/me/my-agent"
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
Per-chat flags:
|
|
127
|
+
|
|
128
|
+
- `isolateTopics: true` — each forum topic gets its own Claude session.
|
|
129
|
+
Default is shared.
|
|
130
|
+
- `requireMention: true` — group chats only respond to `@botname` or
|
|
131
|
+
replies to bot messages. Paired users bypass this.
|
|
132
|
+
- `topics: { "<thread_id>": "<name>" }` — human-readable topic labels
|
|
133
|
+
included in the prompt.
|
|
134
|
+
|
|
135
|
+
Per-bot flags:
|
|
136
|
+
|
|
137
|
+
- `allowConfigCommands: true` — enables `/model`, `/effort`, `/pair-code`,
|
|
138
|
+
`/pairings`, `/unpair`.
|
|
139
|
+
- `streamReplies: true` — live-edit the Telegram message as Claude works.
|
|
140
|
+
- `voice: { enabled, provider: "openai"|"local", ... }` — Whisper
|
|
141
|
+
transcription settings.
|
|
142
|
+
- `approvals: { adminChatId, timeoutMs, gatedTools, ... }` — which tool
|
|
143
|
+
calls require an inline-keyboard approval and where to post the card.
|
|
144
|
+
|
|
145
|
+
See `config.example.json` for the full schema.
|
|
146
|
+
|
|
147
|
+
## Migrating from OpenClaw
|
|
148
|
+
|
|
149
|
+
See the design doc (`docs/polygram-design.md`) for the full trust model
|
|
150
|
+
and architectural choices. In practice:
|
|
151
|
+
|
|
152
|
+
1. Install `polygram`, point chat `cwd` at your migrated agent
|
|
153
|
+
project.
|
|
154
|
+
2. Copy OpenClaw's per-partner memory directories to their new chat
|
|
155
|
+
directories if you used them.
|
|
156
|
+
3. For chats where each OpenClaw topic had its own context, set
|
|
157
|
+
`isolateTopics: true` in chat config.
|
|
158
|
+
4. For cron/scheduled scripts, replace direct Telegram API calls with
|
|
159
|
+
`tell(bot, method, params, {source})` from `lib/ipc-client`. The
|
|
160
|
+
bot process writes to the transcript; the script just asks it to send.
|
|
161
|
+
5. Use `scripts/split-db.js` if you're consolidating multiple OpenClaw
|
|
162
|
+
databases — otherwise per-bot SQLite files start fresh.
|
|
163
|
+
|
|
164
|
+
## Cron → bot (IPC)
|
|
165
|
+
|
|
166
|
+
```js
|
|
167
|
+
const { tell } = require('polygram/lib/ipc-client');
|
|
168
|
+
|
|
169
|
+
await tell('admin-bot', 'sendMessage', {
|
|
170
|
+
chat_id: '123456789',
|
|
171
|
+
text: 'Daily inventory report ready.',
|
|
172
|
+
}, { source: 'cron:inventory-report' });
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
Allowed methods: `sendMessage`, `sendPhoto`, `sendDocument`, `sendSticker`,
|
|
176
|
+
`sendChatAction`, `editMessageText`, `setMessageReaction`. The socket
|
|
177
|
+
server rejects others. Cross-bot sends are rejected (chat must belong
|
|
178
|
+
to the bot on the other end of the socket).
|
|
179
|
+
|
|
180
|
+
If the bot process is down, the call throws. This is intentional — cron
|
|
181
|
+
failures should surface.
|
|
182
|
+
|
|
183
|
+
## The `telegram-history` skill
|
|
184
|
+
|
|
185
|
+
A Claude skill that queries the transcript:
|
|
186
|
+
|
|
187
|
+
```bash
|
|
188
|
+
node skills/telegram-history/scripts/query.js recent -1000000000001 --since 24h
|
|
189
|
+
node skills/telegram-history/scripts/query.js search "invoice" --user Maria
|
|
190
|
+
node skills/telegram-history/scripts/query.js around --chat -100... --msg-id 12345 --before 10
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
Bot scope is derived from `process.cwd()` — the skill refuses to run if
|
|
194
|
+
the cwd doesn't match a chat in config, unless `BRIDGE_ADMIN=1` is set.
|
|
195
|
+
With per-bot DBs the skill opens only the current bot's file; in admin
|
|
196
|
+
mode it unions across all `<bot>.db` files.
|
|
197
|
+
|
|
198
|
+
## Approvals
|
|
199
|
+
|
|
200
|
+
Config:
|
|
201
|
+
|
|
202
|
+
```json
|
|
203
|
+
"approvals": {
|
|
204
|
+
"adminChatId": "123456789",
|
|
205
|
+
"timeoutMs": 300000,
|
|
206
|
+
"gatedTools": ["Bash(rm *)", "mcp__*__invoice_create"]
|
|
207
|
+
}
|
|
208
|
+
```
|
|
209
|
+
|
|
210
|
+
Install the hook at the agent level (`settings.json`):
|
|
211
|
+
|
|
212
|
+
```json
|
|
213
|
+
{
|
|
214
|
+
"hooks": {
|
|
215
|
+
"PreToolUse": [{
|
|
216
|
+
"matcher": "Bash|mcp__*",
|
|
217
|
+
"hooks": [{
|
|
218
|
+
"type": "command",
|
|
219
|
+
"command": "/abs/path/to/polygram/bin/bridge-approval-hook.js"
|
|
220
|
+
}]
|
|
221
|
+
}]
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
```
|
|
225
|
+
|
|
226
|
+
When Claude attempts a matched tool, the hook blocks, the daemon posts
|
|
227
|
+
`[Approve]/[Deny]` buttons to `adminChatId`, and the tool runs (or is
|
|
228
|
+
denied) after the click. Tokens in `callback_data` defeat replay;
|
|
229
|
+
foreign-chat clicks are rejected. Default-deny on IPC error.
|
|
230
|
+
|
|
231
|
+
## Development
|
|
232
|
+
|
|
233
|
+
```bash
|
|
234
|
+
npm test # 336 tests, 72 suites, node:test, no external services
|
|
235
|
+
npm start -- --bot my-bot
|
|
236
|
+
npm run split-db -- --config config.json --dry-run
|
|
237
|
+
npm run ipc-smoke -- my-bot
|
|
238
|
+
```
|
|
239
|
+
|
|
240
|
+
Layout:
|
|
241
|
+
|
|
242
|
+
```
|
|
243
|
+
bridge.js main daemon
|
|
244
|
+
bin/bridge-approval-hook.js PreToolUse hook
|
|
245
|
+
lib/ core modules (db, prompt, telegram,
|
|
246
|
+
process-manager, sessions, history,
|
|
247
|
+
attachments, inbox, voice, approvals,
|
|
248
|
+
pairings, ipc-{server,client},
|
|
249
|
+
session-key, stream-reply, ...)
|
|
250
|
+
migrations/NNN-*.sql applied at boot, guarded by user_version
|
|
251
|
+
skills/telegram-history/ Claude skill
|
|
252
|
+
ops/ LaunchAgent plists
|
|
253
|
+
scripts/split-db.js one-time shared-DB → per-bot migration
|
|
254
|
+
tests/*.test.js node:test
|
|
255
|
+
```
|
|
256
|
+
|
|
257
|
+
## Status and non-goals
|
|
258
|
+
|
|
259
|
+
- Used in production by the author for a retail ops workflow.
|
|
260
|
+
- No horizontal scale-out. One machine, shared filesystem. If you need
|
|
261
|
+
bot A in Bangkok and bot B on AWS, swap SQLite for something networked;
|
|
262
|
+
that's not on the roadmap.
|
|
263
|
+
- Claude Code only. No abstraction over other AIs.
|
|
264
|
+
- macOS LaunchAgent plists included; Linux systemd units are not (easy
|
|
265
|
+
to adapt).
|
|
266
|
+
- No marketplace plugin wrapper yet. See roadmap.
|
|
267
|
+
|
|
268
|
+
## Roadmap
|
|
269
|
+
|
|
270
|
+
- Pairings phase 2: auto-create DM chat entries for paired users in
|
|
271
|
+
unknown chats.
|
|
272
|
+
- Approvals phase 2: deny-with-reason, per-user quotas.
|
|
273
|
+
- Voice phase 2: `/replay-voice` to re-transcribe with a language hint.
|
|
274
|
+
- `/replay-pending` admin command for crashed-mid-send rows.
|
|
275
|
+
- Marketplace plugin wrapper with slash commands for admin.
|
|
276
|
+
|
|
277
|
+
## Licence
|
|
278
|
+
|
|
279
|
+
MIT — see [LICENSE](./LICENSE).
|
|
280
|
+
|
|
281
|
+
## Acknowledgements
|
|
282
|
+
|
|
283
|
+
- [grammy](https://grammy.dev) for the Telegram client.
|
|
284
|
+
- [better-sqlite3](https://github.com/WiseLibs/better-sqlite3) for the
|
|
285
|
+
storage layer.
|
|
286
|
+
- OpenClaw for the per-chat session ergonomics this project aims to
|
|
287
|
+
preserve.
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Claude Code PreToolUse hook -> bridge daemon approval round-trip.
|
|
4
|
+
*
|
|
5
|
+
* Installed into an agent's settings.json:
|
|
6
|
+
* { "hooks": { "PreToolUse": [
|
|
7
|
+
* { "matcher": "Bash|WebFetch|mcp__*", "hooks": [
|
|
8
|
+
* { "type": "command",
|
|
9
|
+
* "command": "/Users/YOURNAME/polygram/bin/bridge-approval-hook.js" }
|
|
10
|
+
* ]}
|
|
11
|
+
* ]}}
|
|
12
|
+
*
|
|
13
|
+
* Environment (set by the bridge when spawning Claude):
|
|
14
|
+
* BRIDGE_BOT - bot name owning this session (socket suffix)
|
|
15
|
+
* BRIDGE_CHAT_ID - chat whose message triggered this turn (for the card)
|
|
16
|
+
* BRIDGE_TURN_ID - optional; helps dedupe re-fires on Claude retries
|
|
17
|
+
*
|
|
18
|
+
* Contract (Claude Code):
|
|
19
|
+
* stdin JSON: { session_id, hook_event_name: "PreToolUse",
|
|
20
|
+
* tool_name, tool_input, ... }
|
|
21
|
+
* stdout JSON reply for PreToolUse: either pass-through (exit 0 empty stdout),
|
|
22
|
+
* or a block decision:
|
|
23
|
+
* {"hookSpecificOutput": {"hookEventName":"PreToolUse",
|
|
24
|
+
* "permissionDecision":"allow"|"deny"|"ask",
|
|
25
|
+
* "permissionDecisionReason":"..."}}
|
|
26
|
+
* Exit codes:
|
|
27
|
+
* 0 - allow (empty stdout) or structured decision in stdout
|
|
28
|
+
* 2 - block (deny)
|
|
29
|
+
*
|
|
30
|
+
* Failure policy: on IPC error (bridge down, socket missing, timeout) we
|
|
31
|
+
* deny by default. Better to block a legitimate tool call than to let a
|
|
32
|
+
* destructive one through when the approver is unreachable.
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
const fs = require('fs');
|
|
36
|
+
|
|
37
|
+
(async () => {
|
|
38
|
+
const botName = process.env.BRIDGE_BOT;
|
|
39
|
+
const chatId = process.env.BRIDGE_CHAT_ID;
|
|
40
|
+
const turnId = process.env.BRIDGE_TURN_ID || null;
|
|
41
|
+
|
|
42
|
+
if (!botName || !chatId) {
|
|
43
|
+
deny('bridge-approval-hook: BRIDGE_BOT and BRIDGE_CHAT_ID env vars required');
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
let req;
|
|
48
|
+
try {
|
|
49
|
+
req = JSON.parse(fs.readFileSync(0, 'utf8'));
|
|
50
|
+
} catch (err) {
|
|
51
|
+
deny(`bad hook input: ${err.message}`);
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
if (req.hook_event_name !== 'PreToolUse') {
|
|
55
|
+
// Not our event; pass through silently.
|
|
56
|
+
process.exit(0);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Resolve relative to this hook's own location rather than a hardcoded
|
|
60
|
+
// absolute path — an absolute-path require is a symlink-swap RCE vector
|
|
61
|
+
// (anyone who can write to that path gets code execution in-bridge).
|
|
62
|
+
const path = require('path');
|
|
63
|
+
const { call, socketPathFor, readSecret } = require(path.join(__dirname, '..', 'lib', 'ipc-client'));
|
|
64
|
+
let res;
|
|
65
|
+
try {
|
|
66
|
+
res = await call({
|
|
67
|
+
path: socketPathFor(botName),
|
|
68
|
+
op: 'approval_request',
|
|
69
|
+
secret: readSecret(botName),
|
|
70
|
+
payload: {
|
|
71
|
+
bot_name: botName,
|
|
72
|
+
chat_id: chatId,
|
|
73
|
+
turn_id: turnId,
|
|
74
|
+
tool_name: req.tool_name,
|
|
75
|
+
tool_input: req.tool_input,
|
|
76
|
+
},
|
|
77
|
+
});
|
|
78
|
+
} catch (err) {
|
|
79
|
+
deny(`bridge unreachable: ${err.message}`);
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
if (!res || !res.ok) {
|
|
84
|
+
deny(`bridge error: ${res?.error || 'unknown'}`);
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Bridge signals one of: 'not-gated' | 'approved' | 'denied' | 'timeout' | 'auto-approved'
|
|
89
|
+
if (res.decision === 'not-gated' || res.decision === 'approved' || res.decision === 'auto-approved') {
|
|
90
|
+
// Pass through — let the default permission flow decide. An empty
|
|
91
|
+
// stdout + exit 0 means "no opinion" from this hook.
|
|
92
|
+
process.exit(0);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const reason = res.reason || `approval ${res.decision}`;
|
|
96
|
+
deny(reason, res.decision);
|
|
97
|
+
})().catch((err) => {
|
|
98
|
+
deny(`hook crashed: ${err.message}`);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
function deny(reason, decision = 'denied') {
|
|
102
|
+
const out = {
|
|
103
|
+
hookSpecificOutput: {
|
|
104
|
+
hookEventName: 'PreToolUse',
|
|
105
|
+
permissionDecision: 'deny',
|
|
106
|
+
permissionDecisionReason: `[${decision}] ${reason}`,
|
|
107
|
+
},
|
|
108
|
+
};
|
|
109
|
+
try {
|
|
110
|
+
process.stdout.write(JSON.stringify(out));
|
|
111
|
+
} catch {}
|
|
112
|
+
process.exit(2);
|
|
113
|
+
}
|