@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.
Files changed (45) hide show
  1. package/CHANGELOG.md +138 -0
  2. package/README.md +3 -2
  3. package/data/cursor-plugin/scripts/pushary-gate.mjs +70 -15
  4. package/data/vscode-plugin/scripts/pushary-gate.mjs +59 -11
  5. package/dist/bin/pushary-claude.js +21 -11
  6. package/dist/bin/pushary-clean.js +60 -22
  7. package/dist/bin/pushary-codex-hook.js +38 -23
  8. package/dist/bin/pushary-codex.js +1 -1
  9. package/dist/bin/pushary-connect.js +6 -4
  10. package/dist/bin/pushary-daemon.js +3 -3
  11. package/dist/bin/pushary-doctor.js +120 -28
  12. package/dist/bin/pushary-gemini-hook.js +32 -16
  13. package/dist/bin/pushary-hook.js +5 -5
  14. package/dist/bin/pushary-login.js +2 -2
  15. package/dist/bin/pushary-logout.js +2 -2
  16. package/dist/bin/pushary-mode.js +2 -2
  17. package/dist/bin/pushary-notification-hook.js +1 -1
  18. package/dist/bin/pushary-permission-denied-hook.js +3 -3
  19. package/dist/bin/pushary-permission-hook.js +3 -3
  20. package/dist/bin/pushary-post-hook.js +1 -1
  21. package/dist/bin/pushary-prompt-hook.js +1 -1
  22. package/dist/bin/pushary-session-end-hook.js +1 -1
  23. package/dist/bin/pushary-session-start-hook.js +1 -1
  24. package/dist/bin/pushary-setup.js +90 -13
  25. package/dist/bin/pushary-stats.js +2 -2
  26. package/dist/bin/pushary-status.js +4 -4
  27. package/dist/bin/pushary-stop-hook.js +1 -1
  28. package/dist/bin/pushary-stopfailure-hook.js +1 -1
  29. package/dist/bin/pushary-suggestions.js +2 -2
  30. package/dist/bin/pushary-upgrade.js +2 -2
  31. package/dist/bin/pushary-wait.js +2 -2
  32. package/dist/bin/pushary.js +1 -1
  33. package/dist/{chunk-JGH37QRB.js → chunk-7OYGFJYZ.js} +1 -1
  34. package/dist/chunk-B7R4YSAB.js +38 -0
  35. package/dist/{chunk-ATP6UQPH.js → chunk-BBK3TTU2.js} +1 -1
  36. package/dist/{chunk-XOUYM27W.js → chunk-FZIQSDTQ.js} +207 -113
  37. package/dist/chunk-GD275GEX.js +66 -0
  38. package/dist/{chunk-SONXGCZY.js → chunk-HU43EZ5Q.js} +45 -27
  39. package/dist/{chunk-KB4ODLGB.js → chunk-I4BYZIX4.js} +2 -0
  40. package/dist/{chunk-YMFZUXL5.js → chunk-MGFZXUUR.js} +57 -4
  41. package/dist/{chunk-YZT2CBC3.js → chunk-NQTRA7H5.js} +1 -1
  42. package/dist/src/index.d.ts +1 -0
  43. package/dist/src/index.js +3 -3
  44. package/package.json +1 -1
  45. 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
  [![npm downloads](https://img.shields.io/npm/dm/@pushary/agent-hooks.svg)](https://www.npmjs.com/package/@pushary/agent-hooks)
5
5
  [![license](https://img.shields.io/npm/l/@pushary/agent-hooks.svg)](./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 Cursor with Pushary
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
- const BASE_URL = 'https://pushary.com'
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
- [/\bsk-[A-Za-z0-9]{20,}\b/g, '[redacted]'],
240
- [/\bpk_(?:live|test)_[A-Za-z0-9]+\b/g, '[redacted]'],
241
- [/\brk_[A-Za-z0-9]+\b/g, '[redacted]'],
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
- [/\bbearer\s+[A-Za-z0-9._-]+/gi, 'bearer [redacted]'],
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*)(\S+)/gi, '$1[redacted]'],
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(redactSecrets(command))
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?.message ?? error}\n`)
475
+ process.stderr.write(`[pushary-gate] ${describeNetworkFailure(error)}\n`)
425
476
  return respond(ask())
426
477
  }
427
478
  }
428
479
 
429
- main().catch((error) => {
430
- process.stderr.write(`[pushary-gate] fatal: ${error?.message ?? error}\n`)
431
- respond(ask())
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
- [/\bsk-[A-Za-z0-9]{20,}\b/g, '[redacted]'],
296
- [/\bpk_(?:live|test)_[A-Za-z0-9]+\b/g, '[redacted]'],
297
- [/\brk_[A-Za-z0-9]+\b/g, '[redacted]'],
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
- [/\bbearer\s+[A-Za-z0-9._-]+/gi, 'bearer [redacted]'],
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*)(\S+)/gi, '$1[redacted]'],
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(redactSecrets(command))
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(String(error?.message ?? error))
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?.message ?? 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
- isDeferAnswer
8
- } from "../chunk-YHG74UFF.js";
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
- resolvePolicy,
20
+ scopePathFor,
19
21
  waitForAnswer
20
- } from "../chunk-XOUYM27W.js";
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 policy = resolvePolicy2(policyConfig, toolName, modeState.mode, input, deps.cwd, repoKeyFor(deps.cwd));
537
- if (policy.timeoutSeconds === 0 && policy.timeoutAction === "approve") return allow(input);
538
- if (policy.timeoutSeconds === 0 && policy.timeoutAction === "deny") {
539
- return deny(`Denied by policy for ${policy.tool}`);
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-KB4ODLGB.js";
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-ATP6UQPH.js";
45
- import "../chunk-JGH37QRB.js";
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
- if (existsSync(CURSOR_PLUGIN_DIR)) {
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
- console.log(` ${green(bold("Clean complete."))}`);
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();