pushary 1.8.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 (186) hide show
  1. package/CHANGELOG.md +1943 -0
  2. package/LICENSE +21 -0
  3. package/README.md +330 -0
  4. package/data/SKILL.md +591 -0
  5. package/data/cowork/SKILL.md +77 -0
  6. package/data/cursor-plugin/.cursor-plugin/plugin.json +25 -0
  7. package/data/cursor-plugin/CHANGELOG.md +70 -0
  8. package/data/cursor-plugin/CONTRIBUTING.md +40 -0
  9. package/data/cursor-plugin/LICENSE +21 -0
  10. package/data/cursor-plugin/README.md +113 -0
  11. package/data/cursor-plugin/SECURITY.md +33 -0
  12. package/data/cursor-plugin/assets/logo.png +0 -0
  13. package/data/cursor-plugin/commands/notify-when-done.md +14 -0
  14. package/data/cursor-plugin/commands/pushary-test.md +13 -0
  15. package/data/cursor-plugin/hooks/hooks.json +75 -0
  16. package/data/cursor-plugin/mcp.json +11 -0
  17. package/data/cursor-plugin/rules/pushary.mdc +40 -0
  18. package/data/cursor-plugin/scripts/pushary-gate.mjs +724 -0
  19. package/data/cursor-plugin/scripts/pushary-gate.test.mjs +137 -0
  20. package/data/cursor-plugin/scripts/redaction.mjs +51 -0
  21. package/data/cursor-plugin/skills/pushary/SKILL.md +584 -0
  22. package/data/vscode-plugin/.claude-plugin/plugin.json +28 -0
  23. package/data/vscode-plugin/.mcp.json +11 -0
  24. package/data/vscode-plugin/CHANGELOG.md +49 -0
  25. package/data/vscode-plugin/CONTRIBUTING.md +40 -0
  26. package/data/vscode-plugin/LICENSE +21 -0
  27. package/data/vscode-plugin/README.md +128 -0
  28. package/data/vscode-plugin/SECURITY.md +56 -0
  29. package/data/vscode-plugin/assets/logo.png +0 -0
  30. package/data/vscode-plugin/commands/notify-when-done.md +14 -0
  31. package/data/vscode-plugin/commands/pushary-test.md +13 -0
  32. package/data/vscode-plugin/hooks/hooks.json +60 -0
  33. package/data/vscode-plugin/scripts/pushary-gate.mjs +762 -0
  34. package/data/vscode-plugin/scripts/redaction.mjs +51 -0
  35. package/data/vscode-plugin/skills/pushary/SKILL.md +584 -0
  36. package/dist/bin/pushary-bell-hook.d.ts +1 -0
  37. package/dist/bin/pushary-bell-hook.js +46 -0
  38. package/dist/bin/pushary-bell.d.ts +1 -0
  39. package/dist/bin/pushary-bell.js +146 -0
  40. package/dist/bin/pushary-claude.d.ts +1 -0
  41. package/dist/bin/pushary-claude.js +1916 -0
  42. package/dist/bin/pushary-clean.d.ts +1 -0
  43. package/dist/bin/pushary-clean.js +1300 -0
  44. package/dist/bin/pushary-codex-bridge.d.ts +1 -0
  45. package/dist/bin/pushary-codex-bridge.js +599 -0
  46. package/dist/bin/pushary-codex-hook.d.ts +1 -0
  47. package/dist/bin/pushary-codex-hook.js +886 -0
  48. package/dist/bin/pushary-codex.d.ts +1 -0
  49. package/dist/bin/pushary-codex.js +125 -0
  50. package/dist/bin/pushary-connect.d.ts +1 -0
  51. package/dist/bin/pushary-connect.js +100 -0
  52. package/dist/bin/pushary-cowork.d.ts +1 -0
  53. package/dist/bin/pushary-cowork.js +36 -0
  54. package/dist/bin/pushary-daemon-supervisor.d.ts +1 -0
  55. package/dist/bin/pushary-daemon-supervisor.js +27 -0
  56. package/dist/bin/pushary-daemon.d.ts +1 -0
  57. package/dist/bin/pushary-daemon.js +878 -0
  58. package/dist/bin/pushary-disconnect.d.ts +1 -0
  59. package/dist/bin/pushary-disconnect.js +264 -0
  60. package/dist/bin/pushary-doctor.d.ts +1 -0
  61. package/dist/bin/pushary-doctor.js +1433 -0
  62. package/dist/bin/pushary-elicitation-hook.d.ts +1 -0
  63. package/dist/bin/pushary-elicitation-hook.js +149 -0
  64. package/dist/bin/pushary-gemini-bridge.d.ts +1 -0
  65. package/dist/bin/pushary-gemini-bridge.js +339 -0
  66. package/dist/bin/pushary-gemini-hook.d.ts +1 -0
  67. package/dist/bin/pushary-gemini-hook.js +381 -0
  68. package/dist/bin/pushary-hook.d.ts +1 -0
  69. package/dist/bin/pushary-hook.js +95 -0
  70. package/dist/bin/pushary-login.d.ts +1 -0
  71. package/dist/bin/pushary-login.js +198 -0
  72. package/dist/bin/pushary-logout.d.ts +1 -0
  73. package/dist/bin/pushary-logout.js +147 -0
  74. package/dist/bin/pushary-mcp.d.ts +1 -0
  75. package/dist/bin/pushary-mcp.js +114 -0
  76. package/dist/bin/pushary-mode.d.ts +1 -0
  77. package/dist/bin/pushary-mode.js +130 -0
  78. package/dist/bin/pushary-notification-hook.d.ts +1 -0
  79. package/dist/bin/pushary-notification-hook.js +56 -0
  80. package/dist/bin/pushary-opencode-hook.d.ts +1 -0
  81. package/dist/bin/pushary-opencode-hook.js +338 -0
  82. package/dist/bin/pushary-permission-denied-hook.d.ts +1 -0
  83. package/dist/bin/pushary-permission-denied-hook.js +60 -0
  84. package/dist/bin/pushary-permission-hook.d.ts +1 -0
  85. package/dist/bin/pushary-permission-hook.js +65 -0
  86. package/dist/bin/pushary-post-hook.d.ts +1 -0
  87. package/dist/bin/pushary-post-hook.js +70 -0
  88. package/dist/bin/pushary-prompt-hook.d.ts +1 -0
  89. package/dist/bin/pushary-prompt-hook.js +61 -0
  90. package/dist/bin/pushary-session-end-hook.d.ts +1 -0
  91. package/dist/bin/pushary-session-end-hook.js +59 -0
  92. package/dist/bin/pushary-session-start-hook.d.ts +1 -0
  93. package/dist/bin/pushary-session-start-hook.js +72 -0
  94. package/dist/bin/pushary-setup.d.ts +1 -0
  95. package/dist/bin/pushary-setup.js +2660 -0
  96. package/dist/bin/pushary-stats.d.ts +1 -0
  97. package/dist/bin/pushary-stats.js +44 -0
  98. package/dist/bin/pushary-status.d.ts +1 -0
  99. package/dist/bin/pushary-status.js +262 -0
  100. package/dist/bin/pushary-stop-hook.d.ts +1 -0
  101. package/dist/bin/pushary-stop-hook.js +63 -0
  102. package/dist/bin/pushary-stopfailure-hook.d.ts +1 -0
  103. package/dist/bin/pushary-stopfailure-hook.js +56 -0
  104. package/dist/bin/pushary-suggestions.d.ts +1 -0
  105. package/dist/bin/pushary-suggestions.js +100 -0
  106. package/dist/bin/pushary-transcript-register.d.ts +1 -0
  107. package/dist/bin/pushary-transcript-register.js +40 -0
  108. package/dist/bin/pushary-transcripts.d.ts +1 -0
  109. package/dist/bin/pushary-transcripts.js +77 -0
  110. package/dist/bin/pushary-upgrade.d.ts +1 -0
  111. package/dist/bin/pushary-upgrade.js +599 -0
  112. package/dist/bin/pushary-wait.d.ts +1 -0
  113. package/dist/bin/pushary-wait.js +122 -0
  114. package/dist/bin/pushary.d.ts +1 -0
  115. package/dist/bin/pushary.js +74 -0
  116. package/dist/chunk-2ABTSGFT.js +173 -0
  117. package/dist/chunk-2OY3ZZ5N.js +124 -0
  118. package/dist/chunk-3EGEA4KH.js +44 -0
  119. package/dist/chunk-3EVPNIBE.js +185 -0
  120. package/dist/chunk-5OP5MQI7.js +45 -0
  121. package/dist/chunk-6J2JC2RT.js +149 -0
  122. package/dist/chunk-7QLSKOSU.js +19 -0
  123. package/dist/chunk-7Z7Q4GGS.js +88 -0
  124. package/dist/chunk-A2A2YTMG.js +835 -0
  125. package/dist/chunk-AHV4LNB5.js +377 -0
  126. package/dist/chunk-AXPCESYO.js +197 -0
  127. package/dist/chunk-C2WCPBF7.js +86 -0
  128. package/dist/chunk-CJVVKOSR.js +73 -0
  129. package/dist/chunk-CULUZTWQ.js +2117 -0
  130. package/dist/chunk-CVQYGMSR.js +142 -0
  131. package/dist/chunk-CVWG2N3Y.js +16 -0
  132. package/dist/chunk-D2FX3EPW.js +15 -0
  133. package/dist/chunk-DB3ONZB4.js +44 -0
  134. package/dist/chunk-DE4Y6TCC.js +160 -0
  135. package/dist/chunk-E4FJTUH4.js +249 -0
  136. package/dist/chunk-EPCXUP3P.js +41 -0
  137. package/dist/chunk-EX3GAPA4.js +44 -0
  138. package/dist/chunk-EYK3GSRH.js +143 -0
  139. package/dist/chunk-GMXKITVA.js +70 -0
  140. package/dist/chunk-GPEGOGRJ.js +49 -0
  141. package/dist/chunk-GU3EJCVV.js +43 -0
  142. package/dist/chunk-HQ6G3B5R.js +59 -0
  143. package/dist/chunk-HZVVNGEW.js +22 -0
  144. package/dist/chunk-IPVKH2ST.js +231 -0
  145. package/dist/chunk-J5TIV3J6.js +238 -0
  146. package/dist/chunk-JU6W3XAU.js +64 -0
  147. package/dist/chunk-JYG4B33G.js +227 -0
  148. package/dist/chunk-JZTHGAPA.js +503 -0
  149. package/dist/chunk-KGDWDSNL.js +102 -0
  150. package/dist/chunk-KVQBWI5T.js +99 -0
  151. package/dist/chunk-KZERVKTD.js +16 -0
  152. package/dist/chunk-LXQ5FBBD.js +47 -0
  153. package/dist/chunk-M5ICYGHT.js +138 -0
  154. package/dist/chunk-M5JDZ45W.js +943 -0
  155. package/dist/chunk-MDEPU45E.js +1226 -0
  156. package/dist/chunk-MPWXGONV.js +157 -0
  157. package/dist/chunk-N6IOSZOE.js +185 -0
  158. package/dist/chunk-NGDD6TXY.js +192 -0
  159. package/dist/chunk-NQTHFF4W.js +2830 -0
  160. package/dist/chunk-OKXS7WDZ.js +68 -0
  161. package/dist/chunk-OLQRRFBX.js +21 -0
  162. package/dist/chunk-P7WQEVWA.js +307 -0
  163. package/dist/chunk-POFBE6ZY.js +12 -0
  164. package/dist/chunk-PQ4K74WL.js +30 -0
  165. package/dist/chunk-PUZZELE6.js +37 -0
  166. package/dist/chunk-Q7E4I6UA.js +767 -0
  167. package/dist/chunk-QCGNGEGE.js +15 -0
  168. package/dist/chunk-R7437YI3.js +151 -0
  169. package/dist/chunk-RHR6NARM.js +305 -0
  170. package/dist/chunk-SAXBC4AZ.js +60 -0
  171. package/dist/chunk-SGZJ5LYH.js +76 -0
  172. package/dist/chunk-SIFVFP6T.js +70 -0
  173. package/dist/chunk-SLFABHW3.js +150 -0
  174. package/dist/chunk-T7E75EKA.js +67 -0
  175. package/dist/chunk-TY4JT7W3.js +38 -0
  176. package/dist/chunk-U4TMAANR.js +111 -0
  177. package/dist/chunk-UI36QSBI.js +177 -0
  178. package/dist/chunk-VQKRG3AC.js +339 -0
  179. package/dist/chunk-WE2J62NC.js +1131 -0
  180. package/dist/chunk-XHKBHWLX.js +14 -0
  181. package/dist/chunk-ZSZSSJCV.js +275 -0
  182. package/dist/chunk-ZVSKMPBZ.js +135 -0
  183. package/dist/reapply-CW3MA66V.js +34 -0
  184. package/dist/src/index.d.ts +204 -0
  185. package/dist/src/index.js +64 -0
  186. package/package.json +108 -0
@@ -0,0 +1,724 @@
1
+ #!/usr/bin/env node
2
+ // Pushary Cursor hooks: server policy, fenced phone approvals, and lifecycle telemetry.
3
+ // Dependency-free for marketplace installs. Regenerate agent-hooks/data after edits.
4
+
5
+ import { redactSecrets, redactSecretsDeep } from './redaction.mjs'
6
+ export { redactSecrets } from './redaction.mjs'
7
+ import { createHash, randomUUID } from 'node:crypto'
8
+ import { hostname } from 'node:os'
9
+ import { existsSync, readFileSync } from 'node:fs'
10
+ import { basename, dirname, isAbsolute, join, posix, resolve } from 'node:path'
11
+ import { fileURLToPath } from 'node:url'
12
+
13
+ const BASE_URL = process.env.PUSHARY_BASE_URL?.trim() || 'https://pushary.com'
14
+ const MCP_URL = `${BASE_URL}/api/mcp/mcp`
15
+ const MAX_BLOCK_MS = 45_000 // longest we can wait before Cursor's hook timeout
16
+ const WAIT_CHUNK_MS = 20_000 // per wait_for_answer long-poll
17
+ const POLL_GAP_MS = 1_500 // pause between polls after a transient error
18
+ const NET_TIMEOUT_MS = 27_000 // abort a single MCP request
19
+ const WITHDRAW_TIMEOUT_MS = 4_000
20
+ const HARD_GUARD_MS = 55_000 // force a graceful "ask" before failClosed (60s) fires
21
+
22
+ // ── Cursor decisions ──────────────────────────────────────────────────────────
23
+ const ALLOW = { permission: 'allow' }
24
+ let genericToolHook = false
25
+ const ask = (agentMessage) => genericToolHook ? deny(agentMessage ?? 'Approval requires a user decision. Ask through Pushary, then retry.') : (agentMessage ? { permission: 'ask', agent_message: agentMessage } : { permission: 'ask' })
26
+ const deny = (agentMessage) => ({ permission: 'deny', user_message: 'Command denied via Pushary.', agent_message: agentMessage })
27
+ // A quiet handoff returns to the agent's permissions, but a file hook has no
28
+ // local prompt to return to. Retrying cannot help, so say why. Same text as the gate route.
29
+ const QUIET_FILE_HOOK_REASON = 'This Cursor file hook cannot show a local approval. Switch to Every time for this action.'
30
+ const handBack = (agentMessage) => genericToolHook ? deny(QUIET_FILE_HOOK_REASON) : ask(agentMessage)
31
+ // No channel can reach anyone, and a file hook has no local prompt to fall back to either.
32
+ const NO_DEVICE_FILE_HOOK_REASON = 'No phone or other notification channel is connected to Pushary, and this Cursor file hook cannot show a local approval. Connect one in Pushary, then retry.'
33
+ const noDevice = () => genericToolHook ? deny(NO_DEVICE_FILE_HOOK_REASON) : ask('No device connected, approve here.')
34
+ const unresolved = (verdict) => genericToolHook && verdict?.reason === 'not_gated'
35
+ ? ALLOW
36
+ : ask(genericToolHook ? 'Pushary could not reach a verdict for this change. Retry shortly.' : undefined)
37
+
38
+ let activeQuestion
39
+ let done = false
40
+ const respond = (decision) => {
41
+ if (done) return
42
+ done = true
43
+ process.stdout.write(JSON.stringify(decision))
44
+ process.exit(0)
45
+ }
46
+
47
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
48
+ const clamp = (n, lo, hi) => Math.min(Math.max(n, lo), hi)
49
+
50
+ const withRetry = async (fn, attempts) => {
51
+ let lastError
52
+ for (let i = 0; i < attempts; i += 1) {
53
+ try {
54
+ return await fn()
55
+ } catch (error) {
56
+ lastError = error
57
+ if (i < attempts - 1) await sleep(300 * (i + 1))
58
+ }
59
+ }
60
+ throw lastError
61
+ }
62
+
63
+ const readStdin = async () => {
64
+ let raw = ''
65
+ for await (const chunk of process.stdin) raw += chunk
66
+ return raw
67
+ }
68
+
69
+ const getMachineId = () => createHash('sha256').update(hostname()).digest('hex').slice(0, 8)
70
+
71
+ // ── Repository identity (ported from packages/agent-hooks/src/repo.ts) ───────
72
+ // A rule can be scoped to one repository, so the gate has to know which one it
73
+ // is in or those rules are withheld. Read as files: a `git` subprocess here would
74
+ // cost more than the rest of the decision and fail where git is absent.
75
+ const REPO_KEY_MAX_LENGTH = 200
76
+ const MAX_PARENT_WALK = 64
77
+
78
+ const normalizeRepoRemote = (remoteUrl) => {
79
+ const raw = (remoteUrl || '').trim()
80
+ if (!raw) return undefined
81
+ let hostAndPath
82
+ const scp = /^[A-Za-z0-9._-]+@([A-Za-z0-9._-]+):(.+)$/.exec(raw)
83
+ if (scp) {
84
+ hostAndPath = `${scp[1]}/${scp[2]}`
85
+ } else {
86
+ const url = /^[A-Za-z][A-Za-z0-9+.-]*:\/\/(.+)$/.exec(raw)
87
+ if (!url || /^file:/i.test(raw)) return undefined
88
+ hostAndPath = url[1]
89
+ }
90
+ const slash = hostAndPath.indexOf('/')
91
+ if (slash <= 0) return undefined
92
+ const at = hostAndPath.slice(0, slash).lastIndexOf('@')
93
+ // Credentials must never survive: a remote can carry a token and this value is
94
+ // sent to the server and persisted.
95
+ const host = (at === -1 ? hostAndPath.slice(0, slash) : hostAndPath.slice(at + 1, slash)).replace(/:\d+$/, '')
96
+ const path = hostAndPath.slice(slash + 1).split('/').filter(Boolean).join('/')
97
+ if (!host || !path) return undefined
98
+ return `${host}/${path}`.replace(/\.git$/i, '').toLowerCase().slice(0, REPO_KEY_MAX_LENGTH)
99
+ }
100
+
101
+ const findGitDir = (startDir) => {
102
+ let current = startDir
103
+ for (let depth = 0; depth < MAX_PARENT_WALK; depth += 1) {
104
+ const candidate = join(current, '.git')
105
+ try {
106
+ if (existsSync(candidate)) {
107
+ const pointer = readFileSync(candidate, 'utf-8').trim()
108
+ // A directory read throws EISDIR, which is the ordinary-clone case.
109
+ if (pointer.startsWith('gitdir:')) {
110
+ const target = pointer.slice('gitdir:'.length).trim()
111
+ return target.startsWith('/') ? target : join(current, target)
112
+ }
113
+ }
114
+ } catch (error) {
115
+ if (error?.code === 'EISDIR') return candidate
116
+ }
117
+ const parent = dirname(current)
118
+ if (parent === current) return undefined
119
+ current = parent
120
+ }
121
+ return undefined
122
+ }
123
+
124
+ const deriveRepoKey = (cwd) => {
125
+ const override = process.env.PUSHARY_REPO_KEY
126
+ if (typeof override === 'string') {
127
+ const trimmed = override.trim()
128
+ if (trimmed.toLowerCase() === 'off') return undefined
129
+ if (trimmed) return trimmed.toLowerCase()
130
+ }
131
+ const dir = cwd || process.cwd()
132
+ try {
133
+ const gitDir = findGitDir(dir)
134
+ if (gitDir) {
135
+ // A worktree's config lives in the main git directory.
136
+ const wt = gitDir.replace(/\\/g, '/').indexOf('/worktrees/')
137
+ const configPath = wt === -1 ? join(gitDir, 'config') : join(gitDir.slice(0, wt), 'config')
138
+ if (existsSync(configPath)) {
139
+ let inOrigin = false
140
+ for (const rawLine of readFileSync(configPath, 'utf-8').split('\n')) {
141
+ const line = rawLine.trim()
142
+ if (line.startsWith('[')) { inOrigin = /^\[remote "origin"\]/.test(line); continue }
143
+ if (!inOrigin) continue
144
+ const url = /^url\s*=\s*(.+)$/.exec(line)
145
+ if (url) {
146
+ const normalized = normalizeRepoRemote(url[1].trim())
147
+ if (normalized) return normalized
148
+ }
149
+ }
150
+ }
151
+ const root = gitDir.endsWith('.git') ? dirname(gitDir) : dir
152
+ return `local/${basename(root).toLowerCase()}`.slice(0, REPO_KEY_MAX_LENGTH)
153
+ }
154
+ return `local/${basename(dir).toLowerCase()}`.slice(0, REPO_KEY_MAX_LENGTH)
155
+ } catch {
156
+ return undefined
157
+ }
158
+ }
159
+
160
+ // Env first. CLI installs inject the key into the sibling mcp.json, so fall back to
161
+ // that. This lets the gate work even when Cursor's GUI does not pass the shell env.
162
+ const resolveApiKey = () => {
163
+ const fromEnv = process.env.PUSHARY_API_KEY
164
+ if (fromEnv) return fromEnv
165
+ try {
166
+ const mcpPath = join(dirname(fileURLToPath(import.meta.url)), '..', 'mcp.json')
167
+ const auth = JSON.parse(readFileSync(mcpPath, 'utf-8'))?.mcpServers?.pushary?.headers?.Authorization ?? ''
168
+ const key = auth.replace(/^Bearer\s+/i, '').trim()
169
+ if (/^pk_[a-z0-9]+\.[a-z0-9]+$/i.test(key)) return key
170
+ } catch {}
171
+ return undefined
172
+ }
173
+
174
+ // ── MCP transport (JSON or SSE) ─────────────────────────────────────────────────
175
+ const parseMcpBody = (body, contentType) => {
176
+ if (contentType && contentType.includes('text/event-stream')) {
177
+ let last = null
178
+ for (const frame of body.split(/\r?\n\r?\n/)) {
179
+ const data = frame
180
+ .split(/\r?\n/)
181
+ .filter((line) => line.startsWith('data:'))
182
+ .map((line) => line.slice(5).trimStart())
183
+ .join('\n')
184
+ .trim()
185
+ if (!data) continue
186
+ try {
187
+ last = JSON.parse(data)
188
+ } catch {}
189
+ }
190
+ if (!last) throw new Error('empty SSE response')
191
+ return last
192
+ }
193
+ return JSON.parse(body)
194
+ }
195
+
196
+ const callTool = async (apiKey, name, args, timeoutMs = NET_TIMEOUT_MS) => {
197
+ const response = await fetch(MCP_URL, {
198
+ method: 'POST',
199
+ headers: {
200
+ 'Content-Type': 'application/json',
201
+ Accept: 'application/json, text/event-stream',
202
+ Authorization: `Bearer ${apiKey}`,
203
+ },
204
+ body: JSON.stringify({ jsonrpc: '2.0', id: Date.now(), method: 'tools/call', params: { name, arguments: args } }),
205
+ signal: AbortSignal.timeout(timeoutMs),
206
+ })
207
+ const text = await response.text()
208
+ if (!response.ok) throw new Error(`Pushary MCP ${response.status}`)
209
+ const rpc = parseMcpBody(text, response.headers.get('content-type'))
210
+ if (rpc.error) throw new Error(rpc.error.message || 'Pushary MCP error')
211
+ const payload = rpc.result?.content?.[0]?.text
212
+ if (!payload) throw new Error('empty Pushary response')
213
+ return JSON.parse(payload)
214
+ }
215
+
216
+ // ── The verdict ─────────────────────────────────────────────────────────────
217
+ // Asked for, not worked out here.
218
+ //
219
+ // This file used to carry its own copy of the authorization boundary: pattern
220
+ // matching, glob compilation, a destructive-command list and policy resolution,
221
+ // roughly two hundred and fifty lines of it. A copy of a rule is a rule that
222
+ // drifts, and this one had already drifted twice. It matched on tool name alone
223
+ // once, so a user's own `Bash(rm:*)` deny did nothing while a broad `Bash` allow
224
+ // approved `rm -rf`. And it never gained `isSafeReadOnlyCommand`, so `git status`
225
+ // reached the phone here and nowhere else.
226
+ //
227
+ // The server now answers with the same `resolveGate` the CLI runs, over the same
228
+ // state, so there is one boundary and this asks it. What stays local is only
229
+ // what a server cannot know: the repository this checkout is in, which needs a
230
+ // parent walk and a git config read on this machine.
231
+
232
+ const GATE_TIMEOUT_MS = 5000
233
+
234
+ /**
235
+ * The verdict for one request, or null if we could not get one.
236
+ *
237
+ * Null is not a denial and not an approval: every failure here hands the call
238
+ * back to Cursor's own prompt, exactly as if this gate were not installed.
239
+ */
240
+ const decide = async (apiKey, request, cwd, sessionId) => {
241
+ try {
242
+ const response = await fetch(`${BASE_URL}/api/agent/gate`, {
243
+ method: 'POST',
244
+ headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${apiKey}` },
245
+ body: JSON.stringify({
246
+ v: 1,
247
+ source: 'cursor',
248
+ toolName: request.toolName,
249
+ toolInputs: request.toolInputs,
250
+ cwd,
251
+ repoKey: deriveRepoKey(cwd),
252
+ sessionId,
253
+ }),
254
+ signal: AbortSignal.timeout(GATE_TIMEOUT_MS),
255
+ })
256
+ if (!response.ok) return null
257
+ const verdict = await response.json()
258
+ return verdict && typeof verdict.kind === 'string' ? verdict : null
259
+ } catch {
260
+ return null
261
+ }
262
+ }
263
+
264
+ // ── network diagnosis ────────────────────────────────────────────────────────
265
+ //
266
+ // This gate is a dependency-free .mjs, so unlike the CLI it cannot install
267
+ // undici's EnvHttpProxyAgent and its fetch ignores HTTP_PROXY entirely. Node
268
+ // gained a native equivalent in 24 (NODE_USE_ENV_PROXY), but that is read at
269
+ // startup, so a script cannot switch it on for itself.
270
+ //
271
+ // The gate already fails safe: any error here hands the decision to the editor's
272
+ // own prompt. The cost is therefore not a blocked command, it is SILENCE. On a
273
+ // corporate network every approval quietly stops reaching the phone and the only
274
+ // trace is one stderr line nobody reads. So when a proxy is configured and the
275
+ // network call fails, say which of those two it is.
276
+ export const PROXY_VARS = ['HTTPS_PROXY', 'https_proxy', 'HTTP_PROXY', 'http_proxy']
277
+
278
+ export const proxyConfigured = (env = process.env) =>
279
+ PROXY_VARS.some(name => (env[name] ?? '').trim() !== '')
280
+
281
+ export const describeNetworkFailure = (error, env = process.env) => {
282
+ const detail = error?.message ?? String(error)
283
+ if (!proxyConfigured(env)) return detail
284
+ const enabled = (env.NODE_USE_ENV_PROXY ?? '').trim() !== ''
285
+ if (enabled) return detail
286
+ 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)`
287
+ }
288
+
289
+ // Secret redaction, two tiers, mirroring SECRET_REDACTION_RULES in @pushary/contracts.
290
+ // The precise rules only match real credential shapes, so they are safe on a line a
291
+ // human reads: a git SHA, a path and prose all survive. The high-entropy catch-all
292
+ // over-redacts by design and is therefore only ever applied to a full body dump.
293
+ //
294
+ // This gate previously did no redaction at all, so the raw command went out in the
295
+ // question, the notification body and the action body alike. A key on the command
296
+ // line reached the lock screen verbatim.
297
+ const ACTION_BODY_MAX = 4000
298
+ const ACTION_BODY_TRUNCATION_MARKER = '\n… [truncated]'
299
+ const capActionBody = (text) =>
300
+ text.length <= ACTION_BODY_MAX ? text : `${text.slice(0, ACTION_BODY_MAX - ACTION_BODY_TRUNCATION_MARKER.length)}${ACTION_BODY_TRUNCATION_MARKER}`
301
+ const deriveActionBody = (command) => capActionBody(redactSecretsDeep(command))
302
+
303
+ // ── ask / wait ───────────────────────────────────────────────────────────────
304
+ // toolTarget and actionBody are what let the server classify risk, drop the
305
+ // one-tap Approve on a dangerous call, and record a decision at the same grain as
306
+ // every other agent. Without them a destructive Cursor command arrived on the
307
+ // lock screen ungated.
308
+ // ask_user accepts a target of at most 80 characters (TOOL_TARGET_MAX_LENGTH in
309
+ // @pushary/contracts). A longer one fails the whole ask.
310
+ const TOOL_TARGET_MAX = 80
311
+
312
+ // A file change is routed and matched by its path, so the ask carries the path the
313
+ // gate judged, resolved against cwd the way the server resolves it. A path too long
314
+ // for ask_user is left out and the server's own target is used instead.
315
+ const filePathTarget = (path, cwd) => {
316
+ if (typeof path !== 'string' || !path) return undefined
317
+ const full = cwd && !isAbsolute(path) ? resolve(cwd, path) : path
318
+ return full.length <= TOOL_TARGET_MAX ? full : undefined
319
+ }
320
+
321
+ // ask_user takes toolPath as an absolute POSIX path of at most 4096 characters and
322
+ // rejects the whole ask for anything else.
323
+ const TOOL_PATH_MAX = 4096
324
+
325
+ // The exact path, so routing and path rules still match a file whose path is too
326
+ // long for toolTarget. Sent only when it is an absolute POSIX path: a relative path
327
+ // with no absolute cwd, or a Windows path, is left out rather than failing the ask.
328
+ const filePathForAsk = (path, cwd) => {
329
+ if (typeof path !== 'string' || !path) return undefined
330
+ if (/^[A-Za-z]:[\\/]/.test(path) || path.startsWith('\\')) return undefined
331
+ const full = path.startsWith('/') ? path
332
+ : typeof cwd === 'string' && cwd.startsWith('/') ? posix.resolve(cwd, path) : undefined
333
+ return full && full.length <= TOOL_PATH_MAX ? full : undefined
334
+ }
335
+
336
+ // The canonical tool and target from a gate verdict, taken by name. Anything else a
337
+ // server adds to questionContext must never overwrite what this gate asks with.
338
+ const canonicalQuestion = (context) => ({
339
+ toolName: typeof context?.toolName === 'string' && context.toolName ? context.toolName : undefined,
340
+ toolTarget: typeof context?.toolTarget === 'string' && context.toolTarget ? context.toolTarget : undefined,
341
+ })
342
+
343
+ // ask_user caps scopePath at SCOPE_PATH_MAX_LENGTH and blocker at
344
+ // DECISION_LINE_MAX. The server derives a cwd-relative scope path that can be
345
+ // longer than 200, so an over-length value must be DROPPED rather than sent:
346
+ // ask_user rejects the whole call, this gate cannot parse the error, and the
347
+ // action fails closed. Dropping costs only the widening on approval.
348
+ const SCOPE_PATH_MAX = 200
349
+ const BLOCKER_MAX = 500
350
+
351
+ const withinCap = (value, cap) =>
352
+ typeof value === 'string' && value && value.length <= cap ? value : undefined
353
+
354
+ /** The scope breach behind an ask, when the verdict says the ask exists for one. */
355
+ const scopeFromVerdict = (verdict) => ({
356
+ scopePath: withinCap(verdict?.scopePath, SCOPE_PATH_MAX),
357
+ scopeReason: withinCap(verdict?.scopeReason, BLOCKER_MAX),
358
+ })
359
+
360
+ const commandHead = (command) => command.trim().split(/\s+/).slice(0, 2).join(' ').slice(0, TOOL_TARGET_MAX)
361
+
362
+ const askArgs = (request, project, ident) => ({
363
+ question: `${request.prompt}\n\n${redactSecrets(request.display)}`,
364
+ type: 'confirm',
365
+ context: `Cursor agent wants to ${request.verb} in ${project}`,
366
+ agentName: ident.agentName,
367
+ sessionId: ident.sessionId,
368
+ machineId: ident.machineId,
369
+ repoKey: ident.repoKey,
370
+ toolName: request.toolName,
371
+ toolTarget: request.toolTarget,
372
+ ...(request.toolPath ? { toolPath: request.toolPath } : {}),
373
+ // Carried from the verdict. Without scopePath the server cannot widen the
374
+ // ratified contract when the user approves, so every further file in the same
375
+ // area asks again; without the blocker the card never says that a boundary the
376
+ // user personally agreed to is the reason for asking.
377
+ ...(request.scopePath ? { scopePath: request.scopePath } : {}),
378
+ ...(request.scopeReason ? { blocker: request.scopeReason } : {}),
379
+ actionBody: deriveActionBody(request.display),
380
+ wait: false,
381
+ waitEndsAt: new Date(Date.now() + MAX_BLOCK_MS).toISOString(),
382
+ })
383
+
384
+ const pollForAnswer = async (apiKey, correlationId, deadlineMs) => {
385
+ while (Date.now() < deadlineMs) {
386
+ const remaining = clamp(deadlineMs - Date.now(), 1_000, WAIT_CHUNK_MS)
387
+ try {
388
+ const answer = await callTool(apiKey, 'wait_for_answer', { correlationId, timeoutMs: remaining })
389
+ if (answer?.error) return STOPPED
390
+ if (answer?.answered || stopped(answer) || (answer?.status && answer.status !== 'pending')) return answer
391
+ } catch {
392
+ return { answered: false, handoffAction: 'stop' }
393
+ }
394
+ await sleep(Math.min(POLL_GAP_MS, Math.max(0, deadlineMs - Date.now())))
395
+ }
396
+ return { answered: false }
397
+ }
398
+
399
+ const fromTimeoutAction = (action, deniedReason) =>
400
+ action === 'approve' ? ALLOW : action === 'deny' ? deny(deniedReason) : ask()
401
+
402
+ const fromAnswer = (answer, deniedReason = 'Denied via Pushary.') => {
403
+ activeQuestion = undefined
404
+ if (answer.value === 'defer') return ask()
405
+ return answer.value === 'yes' ? ALLOW : deny(deniedReason)
406
+ }
407
+
408
+ const stopped = (result) => result?.handoffAction === 'stop'
409
+ || ['cancelled', 'unavailable', 'stopped', 'missing'].includes(result?.status)
410
+ const STOPPED = { answered: false, handoffAction: 'stop' }
411
+ const STOP_REASON = 'This approval was cancelled or its state could not be verified. Do not run the action; wait for a new user instruction.'
412
+
413
+ const withdraw = async (apiKey, correlationId) => {
414
+ try {
415
+ const cancelled = await callTool(apiKey, 'cancel_question', { correlationId }, WITHDRAW_TIMEOUT_MS)
416
+ if (stopped(cancelled)) return STOPPED
417
+ if (cancelled?.cancelled === true) return { answered: false }
418
+ const answer = await callTool(apiKey, 'wait_for_answer', { correlationId, timeoutMs: 1_000 }, WITHDRAW_TIMEOUT_MS)
419
+ if (answer?.answered) return answer
420
+ return ['expired', 'missing'].includes(answer?.status) ? { answered: false } : STOPPED
421
+ } catch {
422
+ return STOPPED
423
+ } finally {
424
+ activeQuestion = undefined
425
+ }
426
+ }
427
+
428
+ const handedOff = (asked) => asked.suppressed || asked.status === 'terminal' || asked.status === 'notified'
429
+ || asked.handoffAction === 'cancel_then_ask_in_current_client'
430
+
431
+ const handoffMessage = (asked) =>
432
+ asked.suppressed ? 'You are at the keyboard, continue with the agent’s permissions.' : 'Continue with the agent’s permissions.'
433
+
434
+ const denialFor = (noun) =>
435
+ `The user denied this ${noun} via a Pushary push approval. Do not run it — propose an alternative or ask how to proceed.`
436
+
437
+ /**
438
+ * What this hook is being asked about, in the one shape the rest of the gate
439
+ * reads.
440
+ *
441
+ * Cursor sends a different payload per event and the gate used to know only one
442
+ * of them: it read `input.command`, called everything "Bash", and any event that
443
+ * was not a shell execution fell out at the empty-command guard as `ask`. That
444
+ * was correct while `beforeShellExecution` was the only thing registered.
445
+ *
446
+ * Returns null for events and tools handled elsewhere.
447
+ */
448
+ const describeRequest = (input) => {
449
+ const event = typeof input.hook_event_name === 'string' ? input.hook_event_name : ''
450
+
451
+ if (event === 'preToolUse') {
452
+ // Shell and MCP have their own gate events; never ask twice.
453
+ if (!['Write', 'Delete', 'Edit'].includes(input.tool_name)) return null
454
+ const params = input.tool_input && typeof input.tool_input === 'object' ? input.tool_input : {}
455
+ const display = `${input.tool_name}\n${JSON.stringify(params)}`
456
+ const toolPath = filePathForAsk(params.file_path ?? params.path, input.cwd)
457
+ return { toolName: input.tool_name, toolInputs: [params], display,
458
+ toolTarget: filePathTarget(params.file_path ?? params.path, input.cwd),
459
+ ...(toolPath ? { toolPath } : {}),
460
+ prompt: 'Allow this file change?', verb: 'change this file', denied: denialFor('file change') }
461
+ }
462
+
463
+ if (event === 'beforeMCPExecution') {
464
+ const tool = typeof input.tool_name === 'string' ? input.tool_name.trim() : ''
465
+ if (!tool) return null
466
+ const server = typeof input.mcp_server_name === 'string' ? input.mcp_server_name.trim() : ''
467
+ // `mcp__<server>__<tool>`, the same spelling Claude Code uses and the one
468
+ // `validateToolPattern` accepts, so a policy someone wrote once matches the
469
+ // same tool whichever agent called it. That portability is the only reason
470
+ // the policy engine is server-side.
471
+ const toolName = server ? `mcp__${server}__${tool}` : `mcp__${tool}`
472
+
473
+ // Never gate Pushary's own MCP tools. Asking Pushary to approve Pushary's
474
+ // ask_user call deadlocks: the approval cannot be delivered until the call
475
+ // it is gating goes through. The Claude hook carries the same guard, where
476
+ // it is a precaution because the matcher does not route these there. Here it
477
+ // is load-bearing: `beforeMCPExecution` is registered with NO matcher, so
478
+ // every MCP call reaches this gate, ours included.
479
+ if (toolName.startsWith('mcp__pushary__')) return null
480
+
481
+ const params = typeof input.tool_input === 'string'
482
+ ? input.tool_input
483
+ : input.tool_input === undefined ? '' : JSON.stringify(input.tool_input)
484
+ return {
485
+ toolName,
486
+ toolInputs: [{ tool: toolName, params }],
487
+ display: params ? `${toolName}\n${params}` : toolName,
488
+ toolTarget: toolName.slice(0, TOOL_TARGET_MAX),
489
+ prompt: 'Allow this tool call?',
490
+ verb: 'call this tool',
491
+ denied: denialFor('tool call'),
492
+ }
493
+ }
494
+
495
+ // Shell is also the fallback for a payload with no event name: a `command` is
496
+ // what `beforeShellExecution` has always been recognised by, and an older
497
+ // Cursor that omits `hook_event_name` must keep working exactly as before.
498
+ const command = typeof input.command === 'string' ? input.command.trim() : ''
499
+ if (!command || (event && event !== 'beforeShellExecution')) return null
500
+ return {
501
+ toolName: 'Bash',
502
+ toolInputs: [{ command }],
503
+ display: command,
504
+ toolTarget: commandHead(command),
505
+ prompt: 'Allow this command?',
506
+ verb: 'run this',
507
+ denied: denialFor('command'),
508
+ }
509
+ }
510
+
511
+ // push_only: wait up to the policy timeout, then apply the timeout action.
512
+ const handlePushOnly = async (apiKey, request, project, ident, timeoutSeconds, timeoutAction) => {
513
+ let asked
514
+ const args = { ...askArgs(request, project, ident), requestId: randomUUID() }
515
+ try {
516
+ asked = await withRetry(() => callTool(apiKey, 'ask_user', args), 3)
517
+ } catch {
518
+ return deny('Pushary could not create a verifiable approval. Retry the action.')
519
+ }
520
+ if (stopped(asked)) return deny(STOP_REASON)
521
+ if (asked?.answered) return fromAnswer(asked)
522
+ if (!asked?.correlationId) return deny('Pushary did not return a verifiable approval.')
523
+ activeQuestion = { apiKey, correlationId: asked.correlationId }
524
+
525
+ // No device first. The server checks for a channel before any quiet mode, and
526
+ // that reply carries the same handoffAction as a quiet handoff.
527
+ if (asked.noDevices) {
528
+ const late = await withdraw(apiKey, asked.correlationId)
529
+ if (stopped(late)) return deny(STOP_REASON)
530
+ if (late.answered) return fromAnswer(late, request.denied)
531
+ return noDevice()
532
+ }
533
+ if (handedOff(asked)) {
534
+ const late = await withdraw(apiKey, asked.correlationId)
535
+ if (stopped(late)) return deny(STOP_REASON)
536
+ if (late.answered) return fromAnswer(late, request.denied)
537
+ return handBack(handoffMessage(asked))
538
+ }
539
+
540
+ const realMs = timeoutAction === 'wait' ? MAX_BLOCK_MS : Math.max(timeoutSeconds, 1) * 1000
541
+ const cap = Math.min(realMs, MAX_BLOCK_MS)
542
+ const deadline = Date.now() + cap
543
+ const answer = await pollForAnswer(apiKey, asked.correlationId, deadline)
544
+ if (stopped(answer)) {
545
+ await withdraw(apiKey, asked.correlationId)
546
+ return deny(STOP_REASON)
547
+ }
548
+ if (answer.answered) return fromAnswer(answer, request.denied)
549
+
550
+ const late = await withdraw(apiKey, asked.correlationId)
551
+ if (stopped(late)) return deny(STOP_REASON)
552
+ if (late.answered) return fromAnswer(late, request.denied)
553
+
554
+ // If Cursor's hook limit cut us off before the configured timeout, hand off to
555
+ // Cursor's own prompt rather than misapplying the policy's timeout action.
556
+ if (cap >= realMs && Date.now() >= deadline) return fromTimeoutAction(timeoutAction, 'No response within the approval timeout; denied per your Pushary policy.')
557
+ return ask()
558
+ }
559
+
560
+ // push_first: race the push for a short window, then fall back to Cursor's prompt.
561
+ const handlePushFirst = async (apiKey, request, project, ident, pushFirstSeconds) => {
562
+ let asked
563
+ const args = { ...askArgs(request, project, ident), requestId: randomUUID() }
564
+ try {
565
+ asked = await withRetry(() => callTool(apiKey, 'ask_user', args), 3)
566
+ } catch {
567
+ return deny('Pushary could not create a verifiable approval. Retry the action.')
568
+ }
569
+ if (stopped(asked)) return deny(STOP_REASON)
570
+ if (asked?.answered) return fromAnswer(asked)
571
+ if (!asked?.correlationId) return deny('Pushary did not return a verifiable approval.')
572
+ activeQuestion = { apiKey, correlationId: asked.correlationId }
573
+
574
+ if (asked.noDevices) {
575
+ const late = await withdraw(apiKey, asked.correlationId)
576
+ if (stopped(late)) return deny(STOP_REASON)
577
+ if (late.answered) return fromAnswer(late, request.denied)
578
+ return noDevice()
579
+ }
580
+ if (handedOff(asked)) {
581
+ const late = await withdraw(apiKey, asked.correlationId)
582
+ if (stopped(late)) return deny(STOP_REASON)
583
+ if (late.answered) return fromAnswer(late, request.denied)
584
+ return handBack(handoffMessage(asked))
585
+ }
586
+
587
+ const cap = Math.min(Math.max(pushFirstSeconds, 1) * 1000, MAX_BLOCK_MS)
588
+ const deadline = Date.now() + cap
589
+ const answer = await pollForAnswer(apiKey, asked.correlationId, deadline)
590
+ if (stopped(answer)) {
591
+ await withdraw(apiKey, asked.correlationId)
592
+ return deny(STOP_REASON)
593
+ }
594
+ if (answer.answered) return fromAnswer(answer, request.denied)
595
+
596
+ const late = await withdraw(apiKey, asked.correlationId)
597
+ if (stopped(late)) return deny(STOP_REASON)
598
+ if (late.answered) return fromAnswer(late, request.denied)
599
+ return ask('No answer from your phone in time, so the request was withdrawn there. Approve here.')
600
+ }
601
+
602
+ // notify_only: fire an awareness notification, let Cursor's prompt decide.
603
+ const handleNotifyOnly = async (apiKey, request, project, ident) => {
604
+ try {
605
+ await callTool(apiKey, 'send_notification', {
606
+ title: 'Agent needs approval',
607
+ body: redactSecrets(request.display).slice(0, 180),
608
+ agentName: ident.agentName,
609
+ sessionId: ident.sessionId,
610
+ machineId: ident.machineId,
611
+ })
612
+ } catch {}
613
+ return handBack()
614
+ }
615
+
616
+ const TELEMETRY_EVENTS = ["postToolUse", "stop", "sessionStart", "sessionEnd", "beforeSubmitPrompt", "preCompact", "subagentStart", "subagentStop"]
617
+
618
+ const reportTelemetry = async (input, source) => {
619
+ const apiKey = resolveApiKey()
620
+ if (!apiKey) return
621
+ const sentAt = new Date().toISOString()
622
+ const payload = JSON.stringify(input, (_key, value) => typeof value === 'string' ? redactSecretsDeep(value) : value)
623
+ if (Buffer.byteLength(payload) > 262144) return
624
+ const hash = value => createHash('sha256').update(value).digest('hex')
625
+ const sessionId = input.session_id ?? input.sessionId ?? input.conversation_id ?? ''
626
+ const hookId = hash([source, sessionId, input.hook_event_name, sentAt, hash(payload)].join('|')).slice(0, 32)
627
+ try {
628
+ await fetch(`${BASE_URL}/api/agent/hook`, {
629
+ method: 'POST',
630
+ headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${apiKey}` },
631
+ body: JSON.stringify({ v: 1, machineId: getMachineId(), app: { platform: 'cli', version: 'editor-plugin' },
632
+ envelopes: [{ wire: 1, hookId, source, event: input.hook_event_name, sentAt,
633
+ cwd: input.cwd, repoKey: deriveRepoKey(input.cwd), payload }] }),
634
+ signal: AbortSignal.timeout(5000),
635
+ })
636
+ } catch { /* Telemetry never blocks an agent. */ }
637
+ }
638
+
639
+ const main = async () => {
640
+ // Backstop: if anything hangs, return "ask" rather than letting the hook time out
641
+ // (which, with failClosed, would block the command).
642
+ setTimeout(async () => {
643
+ if (!activeQuestion) return respond(deny('Pushary could not finish approval safely. Retry the action.'))
644
+ const late = await withdraw(activeQuestion.apiKey, activeQuestion.correlationId)
645
+ respond(late.answered ? fromAnswer(late) : deny('Pushary approval expired or could not be withdrawn safely. Retry the action.'))
646
+ }, HARD_GUARD_MS - 5_000).unref()
647
+
648
+
649
+ let input
650
+ try {
651
+ const raw = await readStdin()
652
+ input = raw.trim() ? JSON.parse(raw) : {}
653
+ } catch {
654
+ return respond(deny('Pushary received invalid hook input. Retry the action.'))
655
+ }
656
+
657
+ input.hook_event_name ??= input.hookEventName
658
+ if (TELEMETRY_EVENTS.includes(input.hook_event_name)) {
659
+ await reportTelemetry(input, 'cursor')
660
+ return respond({})
661
+ }
662
+
663
+ genericToolHook = input.hook_event_name === 'preToolUse'
664
+ const request = describeRequest(input)
665
+ if (!request) return respond({})
666
+
667
+ const apiKey = resolveApiKey()
668
+ if (!apiKey) {
669
+ return respond(
670
+ ask('Pushary is not configured: set the PUSHARY_API_KEY environment variable (get a key at https://pushary.com) to route this approval to your phone.')
671
+ )
672
+ }
673
+
674
+ const project = basename(input.cwd || process.cwd()) || 'workspace'
675
+ const sessionId = typeof input.conversation_id === 'string' ? input.conversation_id : undefined
676
+ const ident = { agentName: `Cursor - ${project}`, sessionId, machineId: getMachineId(), repoKey: deriveRepoKey(input.cwd) }
677
+
678
+ try {
679
+ const verdict = await decide(apiKey, request, input.cwd, sessionId)
680
+
681
+ // Native shell/MCP prompts can take over; generic file hooks must deny ask.
682
+ if (!verdict || verdict.kind === 'no_opinion') return respond(unresolved(verdict))
683
+ if (verdict.kind === 'kill') return respond(deny(verdict.reason))
684
+ if (verdict.kind === 'allow') return respond(ALLOW)
685
+ if (verdict.kind === 'deny') return respond(deny(verdict.reason))
686
+ if (verdict.kind !== 'ask') return respond(ask())
687
+
688
+ // Reuse the server's canonical tool and target, so asking resolves the same rules
689
+ // as the gate. A file change keeps its own path: the server's target for a file
690
+ // is only its extension, which loses what routing and path rules match on.
691
+ const canonical = canonicalQuestion(verdict.questionContext)
692
+ request.toolName = canonical.toolName ?? request.toolName
693
+ request.toolTarget = (genericToolHook && request.toolTarget) || canonical.toolTarget || request.toolTarget
694
+ Object.assign(request, scopeFromVerdict(verdict))
695
+ const tool = verdict.policy
696
+
697
+ switch (tool.mode) {
698
+ case 'terminal_only':
699
+ return respond(handBack())
700
+ case 'notify_only':
701
+ return respond(await handleNotifyOnly(apiKey, request, project, ident))
702
+ case 'push_only':
703
+ return respond(await handlePushOnly(apiKey, request, project, ident, tool.timeoutSeconds, tool.timeoutAction))
704
+ case 'push_first':
705
+ default:
706
+ return respond(await handlePushFirst(apiKey, request, project, ident, tool.pushFirstSeconds))
707
+ }
708
+ } catch (error) {
709
+ process.stderr.write(`[pushary-gate] ${error?.message ?? error}\n`)
710
+ return respond(unresolved(null))
711
+ }
712
+ }
713
+
714
+ // Exported for scripts/pushary-gate.test.mjs. main() only runs when the gate is
715
+ // executed directly, so importing this file for a test does not read stdin.
716
+ export { normalizeRepoRemote, deriveRepoKey, describeRequest, handlePushOnly, handlePushFirst, withdraw }
717
+
718
+ const isDirectRun = process.argv[1] && import.meta.url === `file://${process.argv[1]}`
719
+ if (!isDirectRun) {
720
+ // imported for tests
721
+ } else main().catch((error) => {
722
+ process.stderr.write(`[pushary-gate] fatal: ${error?.message ?? error}\n`)
723
+ respond(ask())
724
+ })