@pushary/agent-hooks 0.68.0 → 0.70.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/CHANGELOG.md +138 -0
- package/README.md +3 -2
- package/data/cursor-plugin/scripts/pushary-gate.mjs +70 -15
- package/data/vscode-plugin/scripts/pushary-gate.mjs +59 -11
- package/dist/bin/pushary-claude.js +21 -11
- package/dist/bin/pushary-clean.js +60 -22
- package/dist/bin/pushary-codex-hook.js +38 -23
- package/dist/bin/pushary-codex.js +1 -1
- package/dist/bin/pushary-connect.js +6 -4
- package/dist/bin/pushary-daemon.js +3 -3
- package/dist/bin/pushary-doctor.js +120 -28
- package/dist/bin/pushary-gemini-hook.js +32 -16
- package/dist/bin/pushary-hook.js +5 -5
- package/dist/bin/pushary-login.js +2 -2
- package/dist/bin/pushary-logout.js +2 -2
- package/dist/bin/pushary-mode.js +2 -2
- package/dist/bin/pushary-notification-hook.js +1 -1
- package/dist/bin/pushary-permission-denied-hook.js +3 -3
- package/dist/bin/pushary-permission-hook.js +3 -3
- package/dist/bin/pushary-post-hook.js +1 -1
- package/dist/bin/pushary-prompt-hook.js +1 -1
- package/dist/bin/pushary-session-end-hook.js +1 -1
- package/dist/bin/pushary-session-start-hook.js +1 -1
- package/dist/bin/pushary-setup.js +90 -13
- package/dist/bin/pushary-stats.js +2 -2
- package/dist/bin/pushary-status.js +4 -4
- package/dist/bin/pushary-stop-hook.js +1 -1
- package/dist/bin/pushary-stopfailure-hook.js +1 -1
- package/dist/bin/pushary-suggestions.js +2 -2
- package/dist/bin/pushary-upgrade.js +2 -2
- package/dist/bin/pushary-wait.js +2 -2
- package/dist/bin/pushary.js +1 -1
- package/dist/{chunk-JGH37QRB.js → chunk-7OYGFJYZ.js} +1 -1
- package/dist/chunk-B7R4YSAB.js +38 -0
- package/dist/{chunk-ATP6UQPH.js → chunk-BBK3TTU2.js} +1 -1
- package/dist/{chunk-XOUYM27W.js → chunk-FZIQSDTQ.js} +207 -113
- package/dist/chunk-GD275GEX.js +66 -0
- package/dist/{chunk-SONXGCZY.js → chunk-HU43EZ5Q.js} +45 -27
- package/dist/{chunk-KB4ODLGB.js → chunk-I4BYZIX4.js} +2 -0
- package/dist/{chunk-YMFZUXL5.js → chunk-MGFZXUUR.js} +57 -4
- package/dist/{chunk-YZT2CBC3.js → chunk-NQTRA7H5.js} +1 -1
- package/dist/src/index.d.ts +1 -0
- package/dist/src/index.js +3 -3
- package/package.json +1 -1
- package/dist/chunk-YHG74UFF.js +0 -12
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,143 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.70.0
|
|
4
|
+
|
|
5
|
+
### A key you name is the key you get
|
|
6
|
+
|
|
7
|
+
Passing `--key` with a malformed value did not fail. Setup read it as no key at
|
|
8
|
+
all, went looking elsewhere, and used whichever key it found: one already stored
|
|
9
|
+
on the machine, one in your environment, or a brand new one it created by pairing.
|
|
10
|
+
You named a credential and quietly got a different one. It now stops and says the
|
|
11
|
+
key is not valid.
|
|
12
|
+
|
|
13
|
+
### setup --json no longer stops to ask a question nobody can see
|
|
14
|
+
|
|
15
|
+
Asking for machine-readable output says plainly that no human is watching, but
|
|
16
|
+
setup could still reach an agent-selection prompt. Piped into a script it waited
|
|
17
|
+
for a keypress that was never coming, and the prompt drew itself onto the same
|
|
18
|
+
output a program was trying to read as JSON. It now selects the agents it detects,
|
|
19
|
+
which is exactly what pressing Enter on that prompt would have chosen.
|
|
20
|
+
|
|
21
|
+
`--json` also promises a single object of output, and every failing exit used to
|
|
22
|
+
print nothing at all. A refused key, a cancelled pairing and a crash were
|
|
23
|
+
indistinguishable to anything reading the output. Each now says which it was.
|
|
24
|
+
|
|
25
|
+
### Setup checks its own work
|
|
26
|
+
|
|
27
|
+
An agent installer that finished without an error was reported as configured,
|
|
28
|
+
even if nothing had been written. Doctor would then contradict setup minutes
|
|
29
|
+
later. Setup now confirms the configuration is really there, using the same check
|
|
30
|
+
doctor uses, so the two cannot disagree.
|
|
31
|
+
|
|
32
|
+
### connect points at the fix that matches your situation
|
|
33
|
+
|
|
34
|
+
`pushary connect --app` gave one answer for every way a phone can be out of
|
|
35
|
+
reach: open the app and allow notifications. That is right when the app is signed
|
|
36
|
+
in and the permission was declined, and wrong otherwise. A terminal that was
|
|
37
|
+
never paired needs setup. A phone signed in to a colleague's account needs your
|
|
38
|
+
account, not a permission toggle. It now says which applies, and when it cannot
|
|
39
|
+
tell the two apart it says both rather than sounding certain about the wrong one.
|
|
40
|
+
|
|
41
|
+
### The editor gates say when a proxy is in the way
|
|
42
|
+
|
|
43
|
+
The Cursor and VS Code gates cannot route through a corporate proxy. They still
|
|
44
|
+
hand the decision safely to the editor's own prompt, so nothing is blocked, but
|
|
45
|
+
every approval quietly stopped reaching your phone with no explanation. When a
|
|
46
|
+
proxy is configured they now name it as the likely cause, and on Node 24 or newer
|
|
47
|
+
they name the setting that fixes it.
|
|
48
|
+
|
|
49
|
+
VS Code is also now listed among the supported agents, which it has been since
|
|
50
|
+
its plugin shipped.
|
|
51
|
+
|
|
52
|
+
## 0.69.0
|
|
53
|
+
|
|
54
|
+
### An approval is never granted on a state Pushary could not confirm
|
|
55
|
+
|
|
56
|
+
When the hook could not reach Pushary, it read that as "you have not been
|
|
57
|
+
stopped, and you are under no agreed scope". Those are answers, and not reaching
|
|
58
|
+
the server is not an answer. A rule you had set to approve automatically could
|
|
59
|
+
therefore fire during an outage on exactly the kind of call a live scope would
|
|
60
|
+
have held back.
|
|
61
|
+
|
|
62
|
+
It now tells the two apart. If the last successful check was under a minute ago,
|
|
63
|
+
that answer still stands, so a brief network blip changes nothing, and a stop you
|
|
64
|
+
issued moments earlier still stops the agent. Past that, an automatic approval is
|
|
65
|
+
withheld and the decision goes to your agent's own prompt instead. Read only
|
|
66
|
+
shell commands are unaffected, because that list is decided on your machine and
|
|
67
|
+
needs no server at all.
|
|
68
|
+
|
|
69
|
+
A rejected key is treated as a real answer rather than a blip, so a key you
|
|
70
|
+
revoked stops being honoured at once.
|
|
71
|
+
|
|
72
|
+
### A scope you agreed to is now honoured everywhere, not just in one place
|
|
73
|
+
|
|
74
|
+
Scope was checked when Claude Code asked before running a tool, and skipped on
|
|
75
|
+
four other paths that could reach the same decision: Claude's permission dialog,
|
|
76
|
+
Codex, Gemini CLI, and the remote wrapper. Each of them fetched the scope you had
|
|
77
|
+
agreed to and then ignored it, so the same edit could be waved through depending
|
|
78
|
+
only on which route it arrived by. All five now run the same check in the same
|
|
79
|
+
order.
|
|
80
|
+
|
|
81
|
+
### Codex patches are judged on every file they touch
|
|
82
|
+
|
|
83
|
+
A Codex patch that changes several files at once was judged on the patch as a
|
|
84
|
+
whole, so a rule you wrote for one file did not apply when that file was part of
|
|
85
|
+
a larger change. Every file is looked at now, and the strictest rule wins. Rules
|
|
86
|
+
written as paths also work for Codex and Gemini, which they quietly did not
|
|
87
|
+
before.
|
|
88
|
+
|
|
89
|
+
### Command details that could contain a secret no longer leave your machine
|
|
90
|
+
|
|
91
|
+
A command like `GH_TOKEN=... gh pr create` carries its credential in the first
|
|
92
|
+
few words, and those words were being sent and stored as the label for what the
|
|
93
|
+
agent wanted to do. Notification text was not being cleaned at all. Both are
|
|
94
|
+
cleaned now, on your machine and again on arrival, and the Cursor and VS Code
|
|
95
|
+
gates clean the command before it is sent rather than after.
|
|
96
|
+
|
|
97
|
+
### setup --dry-run really does change nothing
|
|
98
|
+
|
|
99
|
+
On a machine with no key, a dry run reached the pairing step, drew a QR code,
|
|
100
|
+
waited for your phone, created a real API key, and then said nothing had been
|
|
101
|
+
written. It now stops before anything is created and tells you a real run would
|
|
102
|
+
connect a key first.
|
|
103
|
+
|
|
104
|
+
### clean can no longer leave Cursor blocking your commands
|
|
105
|
+
|
|
106
|
+
Cursor is told to block a matched command if the Pushary gate cannot answer, and
|
|
107
|
+
that list includes ordinary work like rebase, migrate, deploy and publish. Clean
|
|
108
|
+
removed the gate's files before removing that instruction, so if the second step
|
|
109
|
+
failed, Cursor was left blocking all of them on a file that no longer existed,
|
|
110
|
+
under a message saying clean had finished. The order is reversed and checked, and
|
|
111
|
+
if the instruction cannot be removed the files stay put, clean says so, and it
|
|
112
|
+
exits with an error.
|
|
113
|
+
|
|
114
|
+
### Pairing survives a dropped reply
|
|
115
|
+
|
|
116
|
+
If the reply carrying your key was lost in transit, the terminal reported that
|
|
117
|
+
pairing had expired and you started again, while the key it never received stayed
|
|
118
|
+
on your account. The key is now held until your terminal confirms it has it, so
|
|
119
|
+
retrying finishes the pairing you already started. A terminal that cannot reach
|
|
120
|
+
Pushary at all now says so instead of sitting under a QR code for fifteen
|
|
121
|
+
minutes.
|
|
122
|
+
|
|
123
|
+
### doctor reports on the agents you actually use
|
|
124
|
+
|
|
125
|
+
Doctor checked Claude Code whether or not you had it, so setting Pushary up for
|
|
126
|
+
Codex alone produced a column of failures about software you had never installed.
|
|
127
|
+
It now reports on the agents Pushary is set up for, and says plainly when it is
|
|
128
|
+
set up for none.
|
|
129
|
+
|
|
130
|
+
Three things it used to call healthy, it no longer does. A key that lives only in
|
|
131
|
+
a shell profile cannot be read by any hook, so an install where nothing could
|
|
132
|
+
work was passing every check. A key saved inside Claude, Cursor or VS Code that
|
|
133
|
+
no longer matches the one in use, which happens whenever pairing issues a new
|
|
134
|
+
one, went unmentioned. And when doctor could not reach Pushary at all, it said
|
|
135
|
+
everything was fine.
|
|
136
|
+
|
|
137
|
+
Its exit codes now distinguish a broken setup from having no phone connected from
|
|
138
|
+
not being able to reach Pushary, so a script can tell what went wrong. A test
|
|
139
|
+
question nobody answers is now withdrawn instead of sitting on your phone.
|
|
140
|
+
|
|
3
141
|
## 0.67.0
|
|
4
142
|
|
|
5
143
|
### The VS Code agent can now ask for approval on your phone
|
package/README.md
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
[](https://www.npmjs.com/package/@pushary/agent-hooks)
|
|
5
5
|
[](./LICENSE)
|
|
6
6
|
|
|
7
|
-
Push notifications, questions, and permission approvals for AI coding agents. Get a push when your agent finishes work, answer its questions from your phone, and approve risky commands before they run. Works with Claude Code, Codex, Cursor, Gemini CLI, and Hermes.
|
|
7
|
+
Push notifications, questions, and permission approvals for AI coding agents. Get a push when your agent finishes work, answer its questions from your phone, and approve risky commands before they run. Works with Claude Code, Codex, Cursor, VS Code, Gemini CLI, and Hermes.
|
|
8
8
|
|
|
9
9
|
Your agent froze waiting for a yes. This is how it reaches you.
|
|
10
10
|
|
|
@@ -53,6 +53,7 @@ npx @pushary/agent-hooks@latest doctor
|
|
|
53
53
|
| Cursor | yes | yes | yes |
|
|
54
54
|
| Gemini CLI | yes | yes | yes |
|
|
55
55
|
| Hermes | yes | yes | yes |
|
|
56
|
+
| VS Code | yes | yes | yes |
|
|
56
57
|
|
|
57
58
|
Any other MCP client can use notify and ask by pointing at the Pushary MCP endpoint directly.
|
|
58
59
|
|
|
@@ -62,7 +63,7 @@ Any other MCP client can use notify and ask by pointing at the Pushary MCP endpo
|
|
|
62
63
|
asserts this list matches it. Do not edit one without the other. -->
|
|
63
64
|
|
|
64
65
|
```
|
|
65
|
-
setup Configure Claude Code, Codex, Gemini CLI, Hermes, or
|
|
66
|
+
setup Configure Claude Code, Codex, Gemini CLI, Hermes, Cursor, or VS Code with Pushary
|
|
66
67
|
claude Run Claude Code through Pushary, reachable from your phone even when idle
|
|
67
68
|
daemon Keep this machine reachable so your phone can START a new session
|
|
68
69
|
status One screen: which key is in force, whether the server accepts it, what can receive an approval
|
|
@@ -32,7 +32,10 @@ import { existsSync, readFileSync, writeFileSync } from 'node:fs'
|
|
|
32
32
|
import { basename, dirname, join } from 'node:path'
|
|
33
33
|
import { fileURLToPath } from 'node:url'
|
|
34
34
|
|
|
35
|
-
|
|
35
|
+
// Overridable like every other Pushary client (the VS Code gate already was).
|
|
36
|
+
// Hardcoded, this gate could not be pointed at a staging or self-hosted server,
|
|
37
|
+
// so there was no way to exercise it anywhere but production.
|
|
38
|
+
const BASE_URL = process.env.PUSHARY_BASE_URL?.trim() || process.env.PUSHARY_API_URL?.trim() || 'https://pushary.com'
|
|
36
39
|
const MCP_URL = `${BASE_URL}/api/mcp/mcp`
|
|
37
40
|
const POLICY_CACHE_TTL_MS = 5 * 60 * 1000
|
|
38
41
|
const MAX_BLOCK_MS = 45_000 // longest we can wait before Cursor's hook timeout
|
|
@@ -235,24 +238,72 @@ const fetchModeState = async (apiKey, sessionId) => {
|
|
|
235
238
|
// dependency-free hook cannot import the workspace) ──────────────────────────────
|
|
236
239
|
const ACTION_BODY_MAX = 4000
|
|
237
240
|
const ACTION_BODY_TRUNCATION_MARKER = '\n… [truncated]'
|
|
241
|
+
// Two tiers, mirroring SECRET_REDACTION_RULES in @pushary/contracts. The precise
|
|
242
|
+
// rules only match real credential shapes, so they are safe on a line a human
|
|
243
|
+
// reads: a git SHA, a path and prose all survive. The high-entropy catch-all
|
|
244
|
+
// over-redacts by design and is therefore only ever applied to a full body dump.
|
|
245
|
+
//
|
|
246
|
+
// One combined list used to serve both, which meant the only text this gate
|
|
247
|
+
// scrubbed was the action body. The question and the notification body carried
|
|
248
|
+
// the raw command.
|
|
238
249
|
const REDACTION_RULES = [
|
|
239
|
-
[
|
|
240
|
-
[/\
|
|
241
|
-
[/\
|
|
250
|
+
[/-----BEGIN[A-Z0-9 ]*PRIVATE KEY-----[\s\S]*?-----END[A-Z0-9 ]*PRIVATE KEY-----/g, '[redacted key]'],
|
|
251
|
+
[/\bsk-[A-Za-z0-9_-]{16,}\b/g, '[redacted]'],
|
|
252
|
+
[/\b[spr]k_(?:live|test)_[A-Za-z0-9]{8,}\b/g, '[redacted]'],
|
|
253
|
+
[/\bwhsec_[A-Za-z0-9]{16,}\b/g, '[redacted]'],
|
|
254
|
+
[/\b(?:ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9]{36,}\b/g, '[redacted]'],
|
|
255
|
+
[/\bgithub_pat_[A-Za-z0-9_]{22,}\b/g, '[redacted]'],
|
|
256
|
+
[/\bglpat-[A-Za-z0-9_-]{20,}\b/g, '[redacted]'],
|
|
257
|
+
[/\bxox[baprs]-[A-Za-z0-9-]{10,}\b/g, '[redacted]'],
|
|
258
|
+
[/\bAIza[A-Za-z0-9_-]{35}\b/g, '[redacted]'],
|
|
242
259
|
[/\bAKIA[0-9A-Z]{16}\b/g, '[redacted]'],
|
|
243
|
-
[/\
|
|
260
|
+
[/\bnpm_[A-Za-z0-9]{36}\b/g, '[redacted]'],
|
|
261
|
+
[/\bxai-[A-Za-z0-9]{16,}\b/g, '[redacted]'],
|
|
262
|
+
[/\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\b/g, '[redacted]'],
|
|
263
|
+
[/\bbearer\s+[A-Za-z0-9._~+/=-]+/gi, 'bearer [redacted]'],
|
|
244
264
|
[/\bauthorization:\s*\S+/gi, 'authorization: [redacted]'],
|
|
245
|
-
[/((?:secret|token|password|passwd|api[_-]?key|private[_-]?key)\s*[=:]\s*)(
|
|
246
|
-
[/[A-Za-z0-9+/]{40,}={0,2}/g, '[redacted]'],
|
|
265
|
+
[/((?:secret|token|password|passwd|api[_-]?key|access[_-]?key|client[_-]?secret|private[_-]?key)\s*[=:]\s*)("[^"]*"|'[^']*'|\S+)/gi, '$1[redacted]'],
|
|
247
266
|
]
|
|
267
|
+
const HIGH_ENTROPY_RULE = [/[A-Za-z0-9+/]{40,}={0,2}/g, '[redacted]']
|
|
248
268
|
const redactSecrets = (text) => REDACTION_RULES.reduce((acc, [pattern, replacement]) => acc.replace(pattern, replacement), text)
|
|
269
|
+
const redactSecretsDeep = (text) => redactSecrets(text).replace(HIGH_ENTROPY_RULE[0], HIGH_ENTROPY_RULE[1])
|
|
249
270
|
const capActionBody = (text) =>
|
|
250
271
|
text.length <= ACTION_BODY_MAX ? text : `${text.slice(0, ACTION_BODY_MAX - ACTION_BODY_TRUNCATION_MARKER.length)}${ACTION_BODY_TRUNCATION_MARKER}`
|
|
251
|
-
const deriveActionBody = (command) => capActionBody(
|
|
272
|
+
const deriveActionBody = (command) => capActionBody(redactSecretsDeep(command))
|
|
273
|
+
|
|
274
|
+
// ── network diagnosis ────────────────────────────────────────────────────────
|
|
275
|
+
//
|
|
276
|
+
// This gate is a dependency-free .mjs, so unlike the CLI it cannot install
|
|
277
|
+
// undici's EnvHttpProxyAgent and its fetch ignores HTTP_PROXY entirely. Node
|
|
278
|
+
// gained a native equivalent in 24 (NODE_USE_ENV_PROXY), but that is read at
|
|
279
|
+
// startup, so a script cannot switch it on for itself.
|
|
280
|
+
//
|
|
281
|
+
// The gate already fails safe: any error here hands the decision to the editor's
|
|
282
|
+
// own prompt. The cost is therefore not a blocked command, it is SILENCE. On a
|
|
283
|
+
// corporate network every approval quietly stops reaching the phone and the only
|
|
284
|
+
// trace is one stderr line nobody reads. So when a proxy is configured and the
|
|
285
|
+
// network call fails, say which of those two it is.
|
|
286
|
+
export const PROXY_VARS = ['HTTPS_PROXY', 'https_proxy', 'HTTP_PROXY', 'http_proxy']
|
|
287
|
+
|
|
288
|
+
export const proxyConfigured = (env = process.env) =>
|
|
289
|
+
PROXY_VARS.some(name => (env[name] ?? '').trim() !== '')
|
|
290
|
+
|
|
291
|
+
export const describeNetworkFailure = (error, env = process.env) => {
|
|
292
|
+
const detail = error?.message ?? String(error)
|
|
293
|
+
if (!proxyConfigured(env)) return detail
|
|
294
|
+
const enabled = (env.NODE_USE_ENV_PROXY ?? '').trim() !== ''
|
|
295
|
+
if (enabled) return detail
|
|
296
|
+
return `${detail} (a proxy is set in HTTP_PROXY/HTTPS_PROXY and this hook cannot use it; on Node 24+ set NODE_USE_ENV_PROXY=1 in the editor's environment)`
|
|
297
|
+
}
|
|
252
298
|
|
|
253
299
|
// ── ask / wait ───────────────────────────────────────────────────────────────
|
|
300
|
+
// Redacted, not raw. This is the text a human reads on a lock screen and in
|
|
301
|
+
// Slack, and it used to be the command verbatim: `curl -H "Authorization:
|
|
302
|
+
// Bearer ..."` left the machine and landed in the question. The server scrubs
|
|
303
|
+
// this field too, but a credential should never travel to be scrubbed on
|
|
304
|
+
// arrival, and the notify body below was scrubbed nowhere at all.
|
|
254
305
|
const askArgs = (command, project, ident) => ({
|
|
255
|
-
question: `Allow this command?\n\n${command}`,
|
|
306
|
+
question: `Allow this command?\n\n${redactSecrets(command)}`,
|
|
256
307
|
type: 'confirm',
|
|
257
308
|
context: `Cursor agent wants to run this in ${project}`,
|
|
258
309
|
agentName: ident.agentName,
|
|
@@ -353,7 +404,7 @@ const handleNotifyOnly = async (apiKey, command, project, ident) => {
|
|
|
353
404
|
try {
|
|
354
405
|
await callTool(apiKey, 'send_notification', {
|
|
355
406
|
title: 'Agent needs approval',
|
|
356
|
-
body: command.slice(0, 180),
|
|
407
|
+
body: redactSecrets(command).slice(0, 180),
|
|
357
408
|
agentName: ident.agentName,
|
|
358
409
|
sessionId: ident.sessionId,
|
|
359
410
|
machineId: ident.machineId,
|
|
@@ -421,12 +472,16 @@ const main = async () => {
|
|
|
421
472
|
return respond(await handlePushFirst(apiKey, command, project, ident, tool.pushFirstSeconds))
|
|
422
473
|
}
|
|
423
474
|
} catch (error) {
|
|
424
|
-
process.stderr.write(`[pushary-gate] ${error
|
|
475
|
+
process.stderr.write(`[pushary-gate] ${describeNetworkFailure(error)}\n`)
|
|
425
476
|
return respond(ask())
|
|
426
477
|
}
|
|
427
478
|
}
|
|
428
479
|
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
480
|
+
// Guarded like the VS Code gate: importing this file to unit-test its pure
|
|
481
|
+
// helpers must not start a hook run that reads stdin and writes a decision.
|
|
482
|
+
if (!process.env.PUSHARY_GATE_IMPORT) {
|
|
483
|
+
main().catch((error) => {
|
|
484
|
+
process.stderr.write(`[pushary-gate] fatal: ${describeNetworkFailure(error)}\n`)
|
|
485
|
+
respond(ask())
|
|
486
|
+
})
|
|
487
|
+
}
|
|
@@ -291,20 +291,38 @@ const fetchModeState = async (apiKey, sessionId) => {
|
|
|
291
291
|
// dependency-free hook cannot import the workspace) ───────────────────────────
|
|
292
292
|
const ACTION_BODY_MAX = 4000
|
|
293
293
|
const ACTION_BODY_TRUNCATION_MARKER = '\n… [truncated]'
|
|
294
|
+
// Two tiers, mirroring SECRET_REDACTION_RULES in @pushary/contracts. The precise
|
|
295
|
+
// rules only match real credential shapes, so they are safe on a line a human
|
|
296
|
+
// reads: a git SHA, a path and prose all survive. The high-entropy catch-all
|
|
297
|
+
// over-redacts by design and is therefore only ever applied to a full body dump.
|
|
298
|
+
//
|
|
299
|
+
// One combined list used to serve both, which meant the only text this gate
|
|
300
|
+
// scrubbed was the action body. The question and the notification body carried
|
|
301
|
+
// the raw command.
|
|
294
302
|
const REDACTION_RULES = [
|
|
295
|
-
[
|
|
296
|
-
[/\
|
|
297
|
-
[/\
|
|
303
|
+
[/-----BEGIN[A-Z0-9 ]*PRIVATE KEY-----[\s\S]*?-----END[A-Z0-9 ]*PRIVATE KEY-----/g, '[redacted key]'],
|
|
304
|
+
[/\bsk-[A-Za-z0-9_-]{16,}\b/g, '[redacted]'],
|
|
305
|
+
[/\b[spr]k_(?:live|test)_[A-Za-z0-9]{8,}\b/g, '[redacted]'],
|
|
306
|
+
[/\bwhsec_[A-Za-z0-9]{16,}\b/g, '[redacted]'],
|
|
307
|
+
[/\b(?:ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9]{36,}\b/g, '[redacted]'],
|
|
308
|
+
[/\bgithub_pat_[A-Za-z0-9_]{22,}\b/g, '[redacted]'],
|
|
309
|
+
[/\bglpat-[A-Za-z0-9_-]{20,}\b/g, '[redacted]'],
|
|
310
|
+
[/\bxox[baprs]-[A-Za-z0-9-]{10,}\b/g, '[redacted]'],
|
|
311
|
+
[/\bAIza[A-Za-z0-9_-]{35}\b/g, '[redacted]'],
|
|
298
312
|
[/\bAKIA[0-9A-Z]{16}\b/g, '[redacted]'],
|
|
299
|
-
[/\
|
|
313
|
+
[/\bnpm_[A-Za-z0-9]{36}\b/g, '[redacted]'],
|
|
314
|
+
[/\bxai-[A-Za-z0-9]{16,}\b/g, '[redacted]'],
|
|
315
|
+
[/\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\b/g, '[redacted]'],
|
|
316
|
+
[/\bbearer\s+[A-Za-z0-9._~+/=-]+/gi, 'bearer [redacted]'],
|
|
300
317
|
[/\bauthorization:\s*\S+/gi, 'authorization: [redacted]'],
|
|
301
|
-
[/((?:secret|token|password|passwd|api[_-]?key|private[_-]?key)\s*[=:]\s*)(
|
|
302
|
-
[/[A-Za-z0-9+/]{40,}={0,2}/g, '[redacted]'],
|
|
318
|
+
[/((?:secret|token|password|passwd|api[_-]?key|access[_-]?key|client[_-]?secret|private[_-]?key)\s*[=:]\s*)("[^"]*"|'[^']*'|\S+)/gi, '$1[redacted]'],
|
|
303
319
|
]
|
|
320
|
+
const HIGH_ENTROPY_RULE = [/[A-Za-z0-9+/]{40,}={0,2}/g, '[redacted]']
|
|
304
321
|
const redactSecrets = (text) => REDACTION_RULES.reduce((acc, [pattern, replacement]) => acc.replace(pattern, replacement), text)
|
|
322
|
+
const redactSecretsDeep = (text) => redactSecrets(text).replace(HIGH_ENTROPY_RULE[0], HIGH_ENTROPY_RULE[1])
|
|
305
323
|
const capActionBody = (text) =>
|
|
306
324
|
text.length <= ACTION_BODY_MAX ? text : `${text.slice(0, ACTION_BODY_MAX - ACTION_BODY_TRUNCATION_MARKER.length)}${ACTION_BODY_TRUNCATION_MARKER}`
|
|
307
|
-
const deriveActionBody = (command) => capActionBody(
|
|
325
|
+
const deriveActionBody = (command) => capActionBody(redactSecretsDeep(command))
|
|
308
326
|
|
|
309
327
|
// VS Code's terminal tool has used more than one field name for the command, and
|
|
310
328
|
// a plain `tool_input` object is not guaranteed. Read the known spellings and
|
|
@@ -327,9 +345,39 @@ export const shouldGate = (toolName, toolInput) => {
|
|
|
327
345
|
return command
|
|
328
346
|
}
|
|
329
347
|
|
|
348
|
+
// ── network diagnosis ────────────────────────────────────────────────────────
|
|
349
|
+
//
|
|
350
|
+
// This gate is a dependency-free .mjs, so unlike the CLI it cannot install
|
|
351
|
+
// undici's EnvHttpProxyAgent and its fetch ignores HTTP_PROXY entirely. Node
|
|
352
|
+
// gained a native equivalent in 24 (NODE_USE_ENV_PROXY), but that is read at
|
|
353
|
+
// startup, so a script cannot switch it on for itself.
|
|
354
|
+
//
|
|
355
|
+
// The gate already fails safe: any error here hands the decision to the editor's
|
|
356
|
+
// own prompt. The cost is therefore not a blocked command, it is SILENCE. On a
|
|
357
|
+
// corporate network every approval quietly stops reaching the phone and the only
|
|
358
|
+
// trace is one stderr line nobody reads. So when a proxy is configured and the
|
|
359
|
+
// network call fails, say which of those two it is.
|
|
360
|
+
export const PROXY_VARS = ['HTTPS_PROXY', 'https_proxy', 'HTTP_PROXY', 'http_proxy']
|
|
361
|
+
|
|
362
|
+
export const proxyConfigured = (env = process.env) =>
|
|
363
|
+
PROXY_VARS.some(name => (env[name] ?? '').trim() !== '')
|
|
364
|
+
|
|
365
|
+
export const describeNetworkFailure = (error, env = process.env) => {
|
|
366
|
+
const detail = error?.message ?? String(error)
|
|
367
|
+
if (!proxyConfigured(env)) return detail
|
|
368
|
+
const enabled = (env.NODE_USE_ENV_PROXY ?? '').trim() !== ''
|
|
369
|
+
if (enabled) return detail
|
|
370
|
+
return `${detail} (a proxy is set in HTTP_PROXY/HTTPS_PROXY and this hook cannot use it; on Node 24+ set NODE_USE_ENV_PROXY=1 in the editor's environment)`
|
|
371
|
+
}
|
|
372
|
+
|
|
330
373
|
// ── ask / wait ────────────────────────────────────────────────────────────────
|
|
374
|
+
// Redacted, not raw. This is the text a human reads on a lock screen and in
|
|
375
|
+
// Slack, and it used to be the command verbatim: `curl -H "Authorization:
|
|
376
|
+
// Bearer ..."` left the machine and landed in the question. The server scrubs
|
|
377
|
+
// this field too, but a credential should never travel to be scrubbed on
|
|
378
|
+
// arrival, and the notify body below was scrubbed nowhere at all.
|
|
331
379
|
const askArgs = (command, project, ident) => ({
|
|
332
|
-
question: `Allow this command?\n\n${command}`,
|
|
380
|
+
question: `Allow this command?\n\n${redactSecrets(command)}`,
|
|
333
381
|
type: 'confirm',
|
|
334
382
|
context: `VS Code agent wants to run this in ${project}`,
|
|
335
383
|
agentName: ident.agentName,
|
|
@@ -426,7 +474,7 @@ const handleNotifyOnly = async (apiKey, command, project, ident) => {
|
|
|
426
474
|
try {
|
|
427
475
|
await callTool(apiKey, 'send_notification', {
|
|
428
476
|
title: 'Agent needs approval',
|
|
429
|
-
body: command.slice(0, 180),
|
|
477
|
+
body: redactSecrets(command).slice(0, 180),
|
|
430
478
|
agentName: ident.agentName,
|
|
431
479
|
sessionId: ident.sessionId,
|
|
432
480
|
machineId: ident.machineId,
|
|
@@ -494,7 +542,7 @@ const main = async () => {
|
|
|
494
542
|
return respond(await handlePushFirst(apiKey, command, project, ident, tool.pushFirstSeconds))
|
|
495
543
|
}
|
|
496
544
|
} catch (error) {
|
|
497
|
-
diag(
|
|
545
|
+
diag(describeNetworkFailure(error))
|
|
498
546
|
return respond(ask())
|
|
499
547
|
}
|
|
500
548
|
}
|
|
@@ -502,7 +550,7 @@ const main = async () => {
|
|
|
502
550
|
// Importing this file for its testable helpers must not consume stdin or exit.
|
|
503
551
|
if (!process.env.PUSHARY_GATE_IMPORT) {
|
|
504
552
|
main().catch((error) => {
|
|
505
|
-
diag(`fatal: ${error
|
|
553
|
+
diag(`fatal: ${describeNetworkFailure(error)}`)
|
|
506
554
|
respond(ask())
|
|
507
555
|
})
|
|
508
556
|
}
|
|
@@ -4,8 +4,10 @@ import {
|
|
|
4
4
|
spawnClaude
|
|
5
5
|
} from "../chunk-XHKBHWLX.js";
|
|
6
6
|
import {
|
|
7
|
-
|
|
8
|
-
|
|
7
|
+
KILL_REASON,
|
|
8
|
+
isDeferAnswer,
|
|
9
|
+
resolveGate
|
|
10
|
+
} from "../chunk-GD275GEX.js";
|
|
9
11
|
import {
|
|
10
12
|
askUser,
|
|
11
13
|
cancelQuestion,
|
|
@@ -15,9 +17,9 @@ import {
|
|
|
15
17
|
getPolicy,
|
|
16
18
|
repoKeyFor,
|
|
17
19
|
reportEvent,
|
|
18
|
-
|
|
20
|
+
scopePathFor,
|
|
19
21
|
waitForAnswer
|
|
20
|
-
} from "../chunk-
|
|
22
|
+
} from "../chunk-FZIQSDTQ.js";
|
|
21
23
|
import "../chunk-BSZYIAZL.js";
|
|
22
24
|
import "../chunk-SAF6HGAA.js";
|
|
23
25
|
import "../chunk-7QLSKOSU.js";
|
|
@@ -470,7 +472,6 @@ var deny = (message) => ({ behavior: "deny", message });
|
|
|
470
472
|
var createRemoteApprover = (deps) => {
|
|
471
473
|
const fetchModeState2 = deps.fetchModeState ?? fetchModeState;
|
|
472
474
|
const getPolicy2 = deps.getPolicy ?? getPolicy;
|
|
473
|
-
const resolvePolicy2 = deps.resolvePolicy ?? resolvePolicy;
|
|
474
475
|
const askUser2 = deps.askUser ?? askUser;
|
|
475
476
|
const waitForAnswer2 = deps.waitForAnswer ?? waitForAnswer;
|
|
476
477
|
const cancelQuestion2 = deps.cancelQuestion ?? cancelQuestion;
|
|
@@ -531,13 +532,22 @@ var createRemoteApprover = (deps) => {
|
|
|
531
532
|
try {
|
|
532
533
|
const sessionId = deps.getSessionId?.();
|
|
533
534
|
const modeState = await fetchModeState2(deps.apiKey, sessionId);
|
|
534
|
-
if (modeState.kill) return deny("Stopped by user \u2014 this agent was halted from Pushary");
|
|
535
535
|
const policyConfig = await getPolicy2(deps.apiKey, modeState.policyVersion, "claude_code", true);
|
|
536
|
-
const
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
536
|
+
const scopePath = modeState.scope ? scopePathFor(toolName, input, deps.cwd) : void 0;
|
|
537
|
+
const verdict = resolveGate({
|
|
538
|
+
modeState,
|
|
539
|
+
config: policyConfig,
|
|
540
|
+
toolName,
|
|
541
|
+
toolInputs: [input],
|
|
542
|
+
scopePaths: scopePath ? [scopePath] : [],
|
|
543
|
+
cwd: deps.cwd,
|
|
544
|
+
repoKey: repoKeyFor(deps.cwd)
|
|
545
|
+
});
|
|
546
|
+
if (verdict.kind === "kill") return deny(KILL_REASON);
|
|
547
|
+
if (verdict.kind === "allow") return allow(input);
|
|
548
|
+
if (verdict.kind === "deny") return deny(verdict.reason);
|
|
549
|
+
if (verdict.kind === "defer") return deny("Approval state unavailable; denied by default");
|
|
550
|
+
const policy = verdict.policy;
|
|
541
551
|
const description = describeToolCall(toolName, input, "hook");
|
|
542
552
|
const toolTarget = deriveToolTarget(toolName, input);
|
|
543
553
|
let question;
|
|
@@ -8,7 +8,7 @@ import {
|
|
|
8
8
|
shortenHome,
|
|
9
9
|
unregisterPluginLocation,
|
|
10
10
|
vscodeSettingsTargets
|
|
11
|
-
} from "../chunk-
|
|
11
|
+
} from "../chunk-I4BYZIX4.js";
|
|
12
12
|
import {
|
|
13
13
|
removeGeminiSettings
|
|
14
14
|
} from "../chunk-E2U35RLD.js";
|
|
@@ -41,8 +41,8 @@ import {
|
|
|
41
41
|
} from "../chunk-MUEW424A.js";
|
|
42
42
|
import {
|
|
43
43
|
exitOnHelpFlag
|
|
44
|
-
} from "../chunk-
|
|
45
|
-
import "../chunk-
|
|
44
|
+
} from "../chunk-BBK3TTU2.js";
|
|
45
|
+
import "../chunk-7OYGFJYZ.js";
|
|
46
46
|
import "../chunk-7QLSKOSU.js";
|
|
47
47
|
import "../chunk-DQAN3JQP.js";
|
|
48
48
|
import {
|
|
@@ -81,8 +81,11 @@ var dim = (s) => `\x1B[2m${s}\x1B[0m`;
|
|
|
81
81
|
var bold = (s) => `\x1B[1m${s}\x1B[0m`;
|
|
82
82
|
var green = (s) => `\x1B[32m${s}\x1B[0m`;
|
|
83
83
|
var yellow = (s) => `\x1B[33m${s}\x1B[0m`;
|
|
84
|
+
var red = (s) => `\x1B[31m${s}\x1B[0m`;
|
|
84
85
|
var check = green("\u2713");
|
|
85
86
|
var skip = yellow("\u2013");
|
|
87
|
+
var cross = red("\u2717");
|
|
88
|
+
var incompleteClean = false;
|
|
86
89
|
var CLAUDE_SETTINGS = claudeSettings();
|
|
87
90
|
var CLAUDE_SETTINGS_LOCAL = claudeSettingsLocal();
|
|
88
91
|
var CLAUDE_JSON = claudeJson();
|
|
@@ -141,6 +144,39 @@ var guardedAliasRemoval = () => {
|
|
|
141
144
|
var writeJson = (path, data) => {
|
|
142
145
|
guardedWrite(path, JSON.stringify(data, null, 2) + "\n");
|
|
143
146
|
};
|
|
147
|
+
var removeCursorUserHook = () => {
|
|
148
|
+
if (!existsSync(CURSOR_USER_HOOKS)) return "absent";
|
|
149
|
+
let parsed;
|
|
150
|
+
try {
|
|
151
|
+
parsed = JSON.parse(readFileSync(CURSOR_USER_HOOKS, "utf-8"));
|
|
152
|
+
} catch {
|
|
153
|
+
return "failed";
|
|
154
|
+
}
|
|
155
|
+
if (!parsed || typeof parsed !== "object") return "failed";
|
|
156
|
+
const hooks = parsed.hooks ?? {};
|
|
157
|
+
const existing = Array.isArray(hooks.beforeShellExecution) ? hooks.beforeShellExecution : [];
|
|
158
|
+
const others = existing.filter((h) => !String(h.command ?? "").includes("pushary-gate"));
|
|
159
|
+
if (others.length === existing.length) return "absent";
|
|
160
|
+
if (others.length === 0) delete hooks.beforeShellExecution;
|
|
161
|
+
else hooks.beforeShellExecution = others;
|
|
162
|
+
parsed.hooks = hooks;
|
|
163
|
+
try {
|
|
164
|
+
writeJson(CURSOR_USER_HOOKS, parsed);
|
|
165
|
+
} catch {
|
|
166
|
+
return "failed";
|
|
167
|
+
}
|
|
168
|
+
if (dryRun) return "removed";
|
|
169
|
+
try {
|
|
170
|
+
const after = JSON.parse(readFileSync(CURSOR_USER_HOOKS, "utf-8"));
|
|
171
|
+
const stillThere = after?.hooks?.beforeShellExecution ?? [];
|
|
172
|
+
if (Array.isArray(stillThere) && stillThere.some((h) => String(h.command ?? "").includes("pushary-gate"))) {
|
|
173
|
+
return "failed";
|
|
174
|
+
}
|
|
175
|
+
} catch {
|
|
176
|
+
return "failed";
|
|
177
|
+
}
|
|
178
|
+
return "removed";
|
|
179
|
+
};
|
|
144
180
|
var cleanSettingsFile = (path, label) => {
|
|
145
181
|
const data = readJson(path);
|
|
146
182
|
if (!data) {
|
|
@@ -212,29 +248,25 @@ var main = async () => {
|
|
|
212
248
|
} else {
|
|
213
249
|
console.log(` ${skip} Cursor MCP config ${dim("(not found)")}`);
|
|
214
250
|
}
|
|
215
|
-
|
|
251
|
+
const cursorGate = removeCursorUserHook();
|
|
252
|
+
if (cursorGate === "removed") {
|
|
253
|
+
console.log(` ${check} Cursor gate ${dim("(removed from ~/.cursor/hooks.json)")}`);
|
|
254
|
+
} else if (cursorGate === "absent") {
|
|
255
|
+
console.log(` ${skip} Cursor gate ${dim("(not registered)")}`);
|
|
256
|
+
} else {
|
|
257
|
+
incompleteClean = true;
|
|
258
|
+
console.log(` ${cross} Cursor gate ${dim("(could not read or write ~/.cursor/hooks.json)")}`);
|
|
259
|
+
console.log(` ${yellow("!")} The gate is registered failClosed, so matched commands would be blocked.`);
|
|
260
|
+
console.log(` ${dim("Keeping the plugin in place so Cursor keeps working. Fix the file, then re-run clean.")}`);
|
|
261
|
+
}
|
|
262
|
+
if (cursorGate === "failed") {
|
|
263
|
+
console.log(` ${skip} Cursor plugin ${dim("(kept: removing it now would block matched commands)")}`);
|
|
264
|
+
} else if (existsSync(CURSOR_PLUGIN_DIR)) {
|
|
216
265
|
guardedRemove(CURSOR_PLUGIN_DIR);
|
|
217
266
|
console.log(` ${check} Cursor plugin ${dim("(removed from ~/.cursor/plugins/local)")}`);
|
|
218
267
|
} else {
|
|
219
268
|
console.log(` ${skip} Cursor plugin ${dim("(not installed)")}`);
|
|
220
269
|
}
|
|
221
|
-
const cursorHooks = readJson(CURSOR_USER_HOOKS);
|
|
222
|
-
if (cursorHooks) {
|
|
223
|
-
const hooks = cursorHooks.hooks ?? {};
|
|
224
|
-
const existing = Array.isArray(hooks.beforeShellExecution) ? hooks.beforeShellExecution : [];
|
|
225
|
-
const others = existing.filter((h) => !String(h.command ?? "").includes("pushary-gate"));
|
|
226
|
-
if (others.length !== existing.length) {
|
|
227
|
-
if (others.length === 0) delete hooks.beforeShellExecution;
|
|
228
|
-
else hooks.beforeShellExecution = others;
|
|
229
|
-
cursorHooks.hooks = hooks;
|
|
230
|
-
writeJson(CURSOR_USER_HOOKS, cursorHooks);
|
|
231
|
-
console.log(` ${check} Cursor gate ${dim("(removed from ~/.cursor/hooks.json)")}`);
|
|
232
|
-
} else {
|
|
233
|
-
console.log(` ${skip} Cursor gate ${dim("(no pushary entries)")}`);
|
|
234
|
-
}
|
|
235
|
-
} else {
|
|
236
|
-
console.log(` ${skip} Cursor gate ${dim("(no hooks.json)")}`);
|
|
237
|
-
}
|
|
238
270
|
for (const settingsPath of vscodeSettingsTargets(existsSync)) {
|
|
239
271
|
const label = `VS Code plugin ${dim(`(${shortenHome(settingsPath)})`)}`;
|
|
240
272
|
if (!existsSync(settingsPath)) {
|
|
@@ -401,7 +433,12 @@ var main = async () => {
|
|
|
401
433
|
console.log(` ${skip} Global package ${dim("(not installed)")}`);
|
|
402
434
|
}
|
|
403
435
|
console.log();
|
|
404
|
-
|
|
436
|
+
if (incompleteClean) {
|
|
437
|
+
console.log(` ${yellow(bold("Clean finished with something left behind."))}`);
|
|
438
|
+
console.log(` ${dim("See the \u2717 above. Fix it, then re-run")} npx @pushary/agent-hooks@latest clean`);
|
|
439
|
+
} else {
|
|
440
|
+
console.log(` ${green(bold("Clean complete."))}`);
|
|
441
|
+
}
|
|
405
442
|
console.log(` ${dim("Run")} npx @pushary/agent-hooks@latest setup ${dim("to reinstall.")}`);
|
|
406
443
|
if (aliasRemovedFrom.length > 0) {
|
|
407
444
|
console.log();
|
|
@@ -411,5 +448,6 @@ var main = async () => {
|
|
|
411
448
|
}
|
|
412
449
|
console.log(` ${dim("If a")} ${bold("pushary claude")} ${dim("or")} ${bold("pushary daemon")} ${dim("is still running anywhere, stop it (Ctrl-C).")}`);
|
|
413
450
|
console.log();
|
|
451
|
+
if (incompleteClean) process.exit(EXIT.FAILED);
|
|
414
452
|
};
|
|
415
453
|
main();
|