@pushary/agent-hooks 0.74.0 → 0.76.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 (49) hide show
  1. package/CHANGELOG.md +67 -0
  2. package/data/SKILL.md +14 -6
  3. package/data/cursor-plugin/CHANGELOG.md +27 -1
  4. package/data/cursor-plugin/CONTRIBUTING.md +28 -0
  5. package/data/cursor-plugin/README.md +1 -1
  6. package/data/cursor-plugin/scripts/pushary-gate.mjs +305 -131
  7. package/data/cursor-plugin/scripts/pushary-gate.test.mjs +130 -0
  8. package/data/cursor-plugin/skills/pushary/SKILL.md +14 -6
  9. package/data/vscode-plugin/CONTRIBUTING.md +28 -0
  10. package/data/vscode-plugin/skills/pushary/SKILL.md +14 -6
  11. package/dist/bin/pushary-claude.js +18 -3
  12. package/dist/bin/pushary-clean.js +5 -5
  13. package/dist/bin/pushary-codex-hook.js +4 -4
  14. package/dist/bin/pushary-codex.js +2 -2
  15. package/dist/bin/pushary-connect.js +3 -3
  16. package/dist/bin/pushary-doctor.js +7 -7
  17. package/dist/bin/pushary-gemini-hook.js +4 -4
  18. package/dist/bin/pushary-hook.js +6 -6
  19. package/dist/bin/pushary-login.js +2 -2
  20. package/dist/bin/pushary-logout.js +2 -2
  21. package/dist/bin/pushary-mode.js +3 -3
  22. package/dist/bin/pushary-notification-hook.js +2 -2
  23. package/dist/bin/pushary-permission-denied-hook.js +6 -6
  24. package/dist/bin/pushary-permission-hook.js +6 -6
  25. package/dist/bin/pushary-post-hook.js +2 -2
  26. package/dist/bin/pushary-prompt-hook.js +2 -2
  27. package/dist/bin/pushary-session-end-hook.js +2 -2
  28. package/dist/bin/pushary-session-start-hook.js +2 -2
  29. package/dist/bin/pushary-setup.js +17 -13
  30. package/dist/bin/pushary-stats.js +2 -2
  31. package/dist/bin/pushary-status.js +2 -2
  32. package/dist/bin/pushary-stop-hook.js +2 -2
  33. package/dist/bin/pushary-stopfailure-hook.js +2 -2
  34. package/dist/bin/pushary-upgrade.js +4 -4
  35. package/dist/bin/pushary-wait.js +3 -3
  36. package/dist/{chunk-Q5QV4I42.js → chunk-3CBPY2G5.js} +1 -1
  37. package/dist/{chunk-4S3E5DQJ.js → chunk-46UEJXQO.js} +2 -2
  38. package/dist/{chunk-VQSTA23N.js → chunk-7BBUTWBD.js} +1 -1
  39. package/dist/{chunk-HAJA47IK.js → chunk-7L57N7WN.js} +1 -1
  40. package/dist/{chunk-CJET52NP.js → chunk-CC2K2SST.js} +1 -1
  41. package/dist/{chunk-34HABKL7.js → chunk-DJHWBNM3.js} +5 -5
  42. package/dist/{chunk-43NDA7LR.js → chunk-DLBW37U6.js} +1 -1
  43. package/dist/{chunk-3BJUW2SH.js → chunk-IA5GBKNL.js} +1 -1
  44. package/dist/{chunk-NDCIOAL7.js → chunk-IZA7SWXX.js} +1 -1
  45. package/dist/{chunk-PZ4UCGWG.js → chunk-QCYN5VXB.js} +1 -1
  46. package/dist/{chunk-SM7EMSDQ.js → chunk-UPPHYOIZ.js} +2 -2
  47. package/dist/src/index.js +6 -6
  48. package/package.json +1 -1
  49. /package/dist/{chunk-TIZNAOTS.js → chunk-TJJBF73A.js} +0 -0
@@ -32,10 +32,7 @@ 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
- // 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'
35
+ const BASE_URL = 'https://pushary.com'
39
36
  const MCP_URL = `${BASE_URL}/api/mcp/mcp`
40
37
  const POLICY_CACHE_TTL_MS = 5 * 60 * 1000
41
38
  const MAX_BLOCK_MS = 45_000 // longest we can wait before Cursor's hook timeout
@@ -59,14 +56,6 @@ const respond = (decision) => {
59
56
  process.exit(0)
60
57
  }
61
58
 
62
- // Always surfaced in Cursor's hooks output channel, so a silent fall-through to
63
- // "ask" is explainable instead of a mystery.
64
- const diag = (message) => {
65
- try {
66
- process.stderr.write(`[pushary-gate] ${message}\n`)
67
- } catch {}
68
- }
69
-
70
59
  // Backstop: if anything hangs, return "ask" rather than letting the hook time out
71
60
  // (which, with failClosed, would block the command).
72
61
  setTimeout(() => respond(ask()), HARD_GUARD_MS).unref()
@@ -87,27 +76,103 @@ const withRetry = async (fn, attempts) => {
87
76
  throw lastError
88
77
  }
89
78
 
90
- // Cursor's Windows launcher can fail to pipe stdin to a hook (a documented Cursor
91
- // bug: the async stream yields nothing and the hook falls through to "ask"). A
92
- // synchronous read of fd 0 survives several of those cases where the stream does
93
- // not, so try it first and only stream as a fallback.
94
79
  const readStdin = async () => {
80
+ let raw = ''
81
+ for await (const chunk of process.stdin) raw += chunk
82
+ return raw
83
+ }
84
+
85
+ const getMachineId = () => createHash('sha256').update(hostname()).digest('hex').slice(0, 8)
86
+
87
+ // ── Repository identity (ported from packages/agent-hooks/src/repo.ts) ───────
88
+ // A rule can be scoped to one repository, so the gate has to know which one it
89
+ // is in or those rules are withheld. Read as files: a `git` subprocess here would
90
+ // cost more than the rest of the decision and fail where git is absent.
91
+ const REPO_KEY_MAX_LENGTH = 200
92
+ const MAX_PARENT_WALK = 64
93
+
94
+ const normalizeRepoRemote = (remoteUrl) => {
95
+ const raw = (remoteUrl || '').trim()
96
+ if (!raw) return undefined
97
+ let hostAndPath
98
+ const scp = /^[A-Za-z0-9._-]+@([A-Za-z0-9._-]+):(.+)$/.exec(raw)
99
+ if (scp) {
100
+ hostAndPath = `${scp[1]}/${scp[2]}`
101
+ } else {
102
+ const url = /^[A-Za-z][A-Za-z0-9+.-]*:\/\/(.+)$/.exec(raw)
103
+ if (!url || /^file:/i.test(raw)) return undefined
104
+ hostAndPath = url[1]
105
+ }
106
+ const slash = hostAndPath.indexOf('/')
107
+ if (slash <= 0) return undefined
108
+ const at = hostAndPath.slice(0, slash).lastIndexOf('@')
109
+ // Credentials must never survive: a remote can carry a token and this value is
110
+ // sent to the server and persisted.
111
+ const host = (at === -1 ? hostAndPath.slice(0, slash) : hostAndPath.slice(at + 1, slash)).replace(/:\d+$/, '')
112
+ const path = hostAndPath.slice(slash + 1).split('/').filter(Boolean).join('/')
113
+ if (!host || !path) return undefined
114
+ return `${host}/${path}`.replace(/\.git$/i, '').toLowerCase().slice(0, REPO_KEY_MAX_LENGTH)
115
+ }
116
+
117
+ const findGitDir = (startDir) => {
118
+ let current = startDir
119
+ for (let depth = 0; depth < MAX_PARENT_WALK; depth += 1) {
120
+ const candidate = join(current, '.git')
121
+ try {
122
+ if (existsSync(candidate)) {
123
+ const pointer = readFileSync(candidate, 'utf-8').trim()
124
+ // A directory read throws EISDIR, which is the ordinary-clone case.
125
+ if (pointer.startsWith('gitdir:')) {
126
+ const target = pointer.slice('gitdir:'.length).trim()
127
+ return target.startsWith('/') ? target : join(current, target)
128
+ }
129
+ }
130
+ } catch (error) {
131
+ if (error?.code === 'EISDIR') return candidate
132
+ }
133
+ const parent = dirname(current)
134
+ if (parent === current) return undefined
135
+ current = parent
136
+ }
137
+ return undefined
138
+ }
139
+
140
+ const deriveRepoKey = (cwd) => {
141
+ const override = process.env.PUSHARY_REPO_KEY
142
+ if (typeof override === 'string') {
143
+ const trimmed = override.trim()
144
+ if (trimmed.toLowerCase() === 'off') return undefined
145
+ if (trimmed) return trimmed.toLowerCase()
146
+ }
147
+ const dir = cwd || process.cwd()
95
148
  try {
96
- const sync = readFileSync(0, 'utf-8')
97
- if (sync && sync.trim()) return sync
98
- } catch {}
99
- try {
100
- let raw = ''
101
- process.stdin.setEncoding('utf-8')
102
- for await (const chunk of process.stdin) raw += chunk
103
- return raw
149
+ const gitDir = findGitDir(dir)
150
+ if (gitDir) {
151
+ // A worktree's config lives in the main git directory.
152
+ const wt = gitDir.replace(/\\/g, '/').indexOf('/worktrees/')
153
+ const configPath = wt === -1 ? join(gitDir, 'config') : join(gitDir.slice(0, wt), 'config')
154
+ if (existsSync(configPath)) {
155
+ let inOrigin = false
156
+ for (const rawLine of readFileSync(configPath, 'utf-8').split('\n')) {
157
+ const line = rawLine.trim()
158
+ if (line.startsWith('[')) { inOrigin = /^\[remote "origin"\]/.test(line); continue }
159
+ if (!inOrigin) continue
160
+ const url = /^url\s*=\s*(.+)$/.exec(line)
161
+ if (url) {
162
+ const normalized = normalizeRepoRemote(url[1].trim())
163
+ if (normalized) return normalized
164
+ }
165
+ }
166
+ }
167
+ const root = gitDir.endsWith('.git') ? dirname(gitDir) : dir
168
+ return `local/${basename(root).toLowerCase()}`.slice(0, REPO_KEY_MAX_LENGTH)
169
+ }
170
+ return `local/${basename(dir).toLowerCase()}`.slice(0, REPO_KEY_MAX_LENGTH)
104
171
  } catch {
105
- return ''
172
+ return undefined
106
173
  }
107
174
  }
108
175
 
109
- const getMachineId = () => createHash('sha256').update(hostname()).digest('hex').slice(0, 8)
110
-
111
176
  // Env first. CLI installs inject the key into the sibling mcp.json, so fall back to
112
177
  // that. This lets the gate work even when Cursor's GUI does not pass the shell env.
113
178
  const resolveApiKey = () => {
@@ -177,7 +242,11 @@ const getJson = async (path, apiKey, timeoutMs) => {
177
242
  const isPolicyConfig = (d) =>
178
243
  !!d && typeof d === 'object' && Array.isArray(d.policies) && typeof d.defaultTimeoutSeconds === 'number' && typeof d.defaultTimeoutAction === 'string'
179
244
 
180
- const policyCacheFile = (apiKey) => join(tmpdir(), `pushary-policy-${createHash('sha256').update(apiKey).digest('hex').slice(0, 12)}.json`)
245
+ // Keyed by the capability as well as the key: the server returns a different rule
246
+ // set to a client that did not ask for repo-scoped rules, so one key for both
247
+ // would serve the wrong policy across an upgrade.
248
+ const policyCacheFile = (apiKey) =>
249
+ join(tmpdir(), `pushary-policy-${createHash('sha256').update(`${apiKey}:cursor:repoAware`).digest('hex').slice(0, 12)}.json`)
181
250
 
182
251
  const getPolicy = async (apiKey) => {
183
252
  const path = policyCacheFile(apiKey)
@@ -193,7 +262,7 @@ const getPolicy = async (apiKey) => {
193
262
  }
194
263
  try {
195
264
  const fresh = await withRetry(async () => {
196
- const raw = await getJson('/api/mcp/policy', apiKey, POLICY_TIMEOUT_MS)
265
+ const raw = await getJson('/api/mcp/policy?repoAware=1', apiKey, POLICY_TIMEOUT_MS)
197
266
  if (!isPolicyConfig(raw)) throw new Error('invalid policy')
198
267
  return raw
199
268
  }, 2)
@@ -207,9 +276,140 @@ const getPolicy = async (apiKey) => {
207
276
  }
208
277
  }
209
278
 
210
- const resolvePolicy = (config, toolName, modeOverride) => {
211
- const base =
212
- config.policies.find((p) => p.tool === toolName) ??
279
+ // ── Rule matching ────────────────────────────────────────────────────────────
280
+ // Ported from @pushary/contracts (agent-hooks 0.56.0). This file cannot take a
281
+ // dependency a Cursor plugin is cloned, not installed — so the logic is
282
+ // vendored. Keep it in step with packages/contracts/src/index.ts; the cases in
283
+ // scripts/pushary-gate.test.mjs mirror the ones in policy.test.ts.
284
+ //
285
+ // Previously this matched on tool NAME alone, which meant every argument rule was
286
+ // invisible here: a user's own `Bash(rm:*)` deny did nothing and `rm -rf` was
287
+ // auto-approved by a broad `Bash` allow. Cursor was strictly less safe than
288
+ // Claude Code for the same policy.
289
+
290
+ const MATCH_RANKS = ['none', 'tool', 'prefix', 'exact']
291
+ const matchRankWeight = (rank) => MATCH_RANKS.indexOf(rank)
292
+
293
+ const GLOB_MAX_WILDCARDS = 8
294
+ const GLOB_MAX_ARG_LENGTH = 4096
295
+
296
+ // Offset propagation rather than a compiled regex: several `**` render as `.*`
297
+ // and backtrack catastrophically on a long non-matching argument, which would
298
+ // hang this gate and, with failClosed, block the command.
299
+ const globMatches = (glob, text) => {
300
+ let stars = 0
301
+ for (const ch of glob) if (ch === '*') stars += 1
302
+ if (stars > GLOB_MAX_WILDCARDS) return false
303
+
304
+ const tokens = []
305
+ let literal = ''
306
+ const flush = () => { if (literal) { tokens.push({ literal }); literal = '' } }
307
+ for (let i = 0; i < glob.length; i += 1) {
308
+ if (glob[i] !== '*') { literal += glob[i]; continue }
309
+ flush()
310
+ if (glob[i + 1] === '*') { tokens.push({ any: true }); i += 1 } else { tokens.push({ any: false }) }
311
+ }
312
+ flush()
313
+
314
+ const n = text.length
315
+ let reach = new Uint8Array(n + 1)
316
+ reach[0] = 1
317
+ for (const token of tokens) {
318
+ const next = new Uint8Array(n + 1)
319
+ if (token.literal !== undefined) {
320
+ for (let p = 0; p <= n - token.literal.length; p += 1) {
321
+ if (reach[p] && text.startsWith(token.literal, p)) next[p + token.literal.length] = 1
322
+ }
323
+ } else {
324
+ let open = 0
325
+ for (let p = 0; p <= n; p += 1) {
326
+ if (!token.any && p > 0 && text.charCodeAt(p - 1) === 47) open = 0
327
+ if (reach[p]) open = 1
328
+ if (open) next[p] = 1
329
+ }
330
+ }
331
+ reach = next
332
+ }
333
+ return reach[n] === 1
334
+ }
335
+
336
+ const matchToolPattern = (pattern, toolName, arg) => {
337
+ const open = pattern.indexOf('(')
338
+ if (open === -1 || !pattern.endsWith(')')) return pattern === toolName ? 'tool' : 'none'
339
+ if (pattern.slice(0, open) !== toolName || arg === undefined) return 'none'
340
+ const inner = pattern.slice(open + 1, -1)
341
+ if (inner.endsWith(':*')) return arg.startsWith(inner.slice(0, -2)) ? 'prefix' : 'none'
342
+ if (inner.includes('*')) {
343
+ if (arg.length > GLOB_MAX_ARG_LENGTH) return 'none'
344
+ return globMatches(inner, arg) ? 'prefix' : 'none'
345
+ }
346
+ return arg === inner ? 'exact' : 'none'
347
+ }
348
+
349
+ // A rule with no repoKey applies everywhere; a scoped one only in its own repo,
350
+ // and never when the repo could not be established.
351
+ const repoMatches = (ruleRepoKey, currentRepoKey) => !ruleRepoKey || ruleRepoKey === currentRepoKey
352
+
353
+ // Rank first, then a repo-scoped rule over a workspace-wide one, then the longer
354
+ // pattern — the same precedence the hook applies.
355
+ const findBestMatch = (policies, toolName, arg, repoKey) => {
356
+ let best
357
+ let bestWeight = 0
358
+ let bestScoped = -1
359
+ let bestLength = -1
360
+ for (const candidate of policies) {
361
+ if (!repoMatches(candidate.repoKey, repoKey)) continue
362
+ const rank = matchToolPattern(candidate.tool, toolName, arg)
363
+ if (rank === 'none') continue
364
+ const weight = matchRankWeight(rank)
365
+ const scoped = candidate.repoKey ? 1 : 0
366
+ const length = rank === 'prefix' ? candidate.tool.length : -1
367
+ if (
368
+ weight > bestWeight ||
369
+ (weight === bestWeight && scoped > bestScoped) ||
370
+ (weight === bestWeight && scoped === bestScoped && length > bestLength)
371
+ ) {
372
+ best = { policy: candidate, rank }
373
+ bestWeight = weight
374
+ bestScoped = scoped
375
+ bestLength = length
376
+ }
377
+ }
378
+ return best
379
+ }
380
+
381
+ // The destructive ceiling. A call the classifier flags must never auto-approve
382
+ // through a general bare-tool or wildcard rule, so the "destructive always asks"
383
+ // guarantee holds for wrapped or unlisted commands too. An explicit rule the user
384
+ // wrote still wins. Mirrors APPROVAL_FATIGUE_FLAG_PATTERNS in contracts.
385
+ const DESTRUCTIVE_PATTERNS = [
386
+ /\brm\s+-[a-z]*r/i,
387
+ /\brmdir\b/i,
388
+ /git\s+push[^\n]*(--force|--force-with-lease|\s-f\b)/i,
389
+ /git\s+reset\s+--hard/i,
390
+ /git\s+clean\s+-[a-z]*f/i,
391
+ /\bdrop\s+(table|database|schema)\b/i,
392
+ /\bdelete\s+from\b/i,
393
+ /\btruncate\b/i,
394
+ /\b(deploy|publish|release)\b/i,
395
+ /\bnpm\s+publish\b/i,
396
+ /\bsudo\b/i,
397
+ /chmod\s+-?R?\s*777/i,
398
+ /\bkubectl\s+delete\b/i,
399
+ /\bterraform\s+(apply|destroy)\b/i,
400
+ /\bdocker\s+system\s+prune\b/i,
401
+ ]
402
+
403
+ const isDestructive = (command) => DESTRUCTIVE_PATTERNS.some((re) => re.test(command))
404
+
405
+ // The safe-read-only floor is deliberately NOT ported. It only ever loosens a
406
+ // decision, and hooks.json already routes just the risky commands here, so
407
+ // omitting it can cost an extra prompt but can never approve something unsafe.
408
+
409
+ const resolvePolicy = (config, toolName, modeOverride, command, repoKey) => {
410
+ const match = findBestMatch(config.policies, toolName, command, repoKey)
411
+ let base =
412
+ match?.policy ??
213
413
  config.policies.find((p) => p.tool === '*') ??
214
414
  {
215
415
  tool: toolName,
@@ -218,6 +418,19 @@ const resolvePolicy = (config, toolName, modeOverride) => {
218
418
  mode: config.defaultMode ?? 'push_first',
219
419
  pushFirstSeconds: config.defaultPushFirstSeconds ?? 20,
220
420
  }
421
+
422
+ const governedBySpecificRule = match?.rank === 'exact' || match?.rank === 'prefix'
423
+ const wouldAutoApprove = base.timeoutSeconds === 0 && base.timeoutAction === 'approve'
424
+ if (!governedBySpecificRule && wouldAutoApprove && typeof command === 'string' && isDestructive(command)) {
425
+ base = {
426
+ tool: base.tool,
427
+ timeoutSeconds: config.defaultTimeoutSeconds,
428
+ timeoutAction: 'wait',
429
+ mode: 'push_first',
430
+ pushFirstSeconds: config.defaultPushFirstSeconds ?? 20,
431
+ }
432
+ }
433
+
221
434
  const effective = modeOverride ?? config.modeOverride
222
435
  return effective ? { ...base, mode: effective } : base
223
436
  }
@@ -234,43 +447,6 @@ const fetchModeState = async (apiKey, sessionId) => {
234
447
  }
235
448
  }
236
449
 
237
- // ── action body capture + redaction (inlined mirror of describe.ts, since this
238
- // dependency-free hook cannot import the workspace) ──────────────────────────────
239
- const ACTION_BODY_MAX = 4000
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.
249
- const REDACTION_RULES = [
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]'],
259
- [/\bAKIA[0-9A-Z]{16}\b/g, '[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]'],
264
- [/\bauthorization:\s*\S+/gi, 'authorization: [redacted]'],
265
- [/((?:secret|token|password|passwd|api[_-]?key|access[_-]?key|client[_-]?secret|private[_-]?key)\s*[=:]\s*)("[^"]*"|'[^']*'|\S+)/gi, '$1[redacted]'],
266
- ]
267
- const HIGH_ENTROPY_RULE = [/[A-Za-z0-9+/]{40,}={0,2}/g, '[redacted]']
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])
270
- const capActionBody = (text) =>
271
- text.length <= ACTION_BODY_MAX ? text : `${text.slice(0, ACTION_BODY_MAX - ACTION_BODY_TRUNCATION_MARKER.length)}${ACTION_BODY_TRUNCATION_MARKER}`
272
- const deriveActionBody = (command) => capActionBody(redactSecretsDeep(command))
273
-
274
450
  // ── network diagnosis ────────────────────────────────────────────────────────
275
451
  //
276
452
  // This gate is a dependency-free .mjs, so unlike the CLI it cannot install
@@ -296,12 +472,48 @@ export const describeNetworkFailure = (error, env = process.env) => {
296
472
  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
473
  }
298
474
 
475
+ // Secret redaction, two tiers, mirroring SECRET_REDACTION_RULES in @pushary/contracts.
476
+ // The precise rules only match real credential shapes, so they are safe on a line a
477
+ // human reads: a git SHA, a path and prose all survive. The high-entropy catch-all
478
+ // over-redacts by design and is therefore only ever applied to a full body dump.
479
+ //
480
+ // This gate previously did no redaction at all, so the raw command went out in the
481
+ // question, the notification body and the action body alike. A key on the command
482
+ // line reached the lock screen verbatim.
483
+ const ACTION_BODY_MAX = 4000
484
+ const ACTION_BODY_TRUNCATION_MARKER = '\n… [truncated]'
485
+ const REDACTION_RULES = [
486
+ [/-----BEGIN[A-Z0-9 ]*PRIVATE KEY-----[\s\S]*?-----END[A-Z0-9 ]*PRIVATE KEY-----/g, '[redacted key]'],
487
+ [/\bsk-[A-Za-z0-9_-]{16,}\b/g, '[redacted]'],
488
+ [/\b[spr]k_(?:live|test)_[A-Za-z0-9]{8,}\b/g, '[redacted]'],
489
+ [/\bwhsec_[A-Za-z0-9]{16,}\b/g, '[redacted]'],
490
+ [/\b(?:ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9]{36,}\b/g, '[redacted]'],
491
+ [/\bgithub_pat_[A-Za-z0-9_]{22,}\b/g, '[redacted]'],
492
+ [/\bglpat-[A-Za-z0-9_-]{20,}\b/g, '[redacted]'],
493
+ [/\bxox[baprs]-[A-Za-z0-9-]{10,}\b/g, '[redacted]'],
494
+ [/\bAIza[A-Za-z0-9_-]{35}\b/g, '[redacted]'],
495
+ [/\bAKIA[0-9A-Z]{16}\b/g, '[redacted]'],
496
+ [/\bnpm_[A-Za-z0-9]{36}\b/g, '[redacted]'],
497
+ [/\bxai-[A-Za-z0-9]{16,}\b/g, '[redacted]'],
498
+ [/\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\b/g, '[redacted]'],
499
+ [/\bbearer\s+[A-Za-z0-9._~+/=-]+/gi, 'bearer [redacted]'],
500
+ [/\bauthorization:\s*\S+/gi, 'authorization: [redacted]'],
501
+ [/((?:secret|token|password|passwd|api[_-]?key|access[_-]?key|client[_-]?secret|private[_-]?key)\s*[=:]\s*)(\"[^\"]*\"|'[^']*'|\S+)/gi, '$1[redacted]'],
502
+ ]
503
+ const HIGH_ENTROPY_RULE = [/[A-Za-z0-9+/]{40,}={0,2}/g, '[redacted]']
504
+ export const redactSecrets = (text) => REDACTION_RULES.reduce((acc, [pattern, replacement]) => acc.replace(pattern, replacement), text)
505
+ const redactSecretsDeep = (text) => redactSecrets(text).replace(HIGH_ENTROPY_RULE[0], HIGH_ENTROPY_RULE[1])
506
+ const capActionBody = (text) =>
507
+ text.length <= ACTION_BODY_MAX ? text : `${text.slice(0, ACTION_BODY_MAX - ACTION_BODY_TRUNCATION_MARKER.length)}${ACTION_BODY_TRUNCATION_MARKER}`
508
+ const deriveActionBody = (command) => capActionBody(redactSecretsDeep(command))
509
+
299
510
  // ── 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.
511
+ // toolTarget and actionBody are what let the server classify risk, drop the
512
+ // one-tap Approve on a dangerous call, and record a decision at the same grain as
513
+ // every other agent. Without them a destructive Cursor command arrived on the
514
+ // lock screen ungated.
515
+ const commandHead = (command) => command.trim().split(/\s+/).slice(0, 2).join(' ').slice(0, 120)
516
+
305
517
  const askArgs = (command, project, ident) => ({
306
518
  question: `Allow this command?\n\n${redactSecrets(command)}`,
307
519
  type: 'confirm',
@@ -310,6 +522,7 @@ const askArgs = (command, project, ident) => ({
310
522
  sessionId: ident.sessionId,
311
523
  machineId: ident.machineId,
312
524
  toolName: 'Bash',
525
+ toolTarget: commandHead(command),
313
526
  actionBody: deriveActionBody(command),
314
527
  wait: false,
315
528
  })
@@ -346,25 +559,10 @@ const handlePushOnly = async (apiKey, command, project, ident, timeoutSeconds, t
346
559
  }
347
560
  if (!asked?.correlationId) return ask()
348
561
 
349
- // Keyboard bypass: at the keyboard, so Cursor's own prompt handles it now.
350
- if (asked.suppressed) {
351
- await callTool(apiKey, 'cancel_question', { correlationId: asked.correlationId }).catch(() => {})
352
- return ask('You are at the keyboard — approve here.')
353
- }
354
- // No reachable device: apply the configured fallback immediately.
355
- if (asked.noDevices) {
356
- return fromTimeoutAction(timeoutAction, 'No device connected to approve on; denied per your Pushary policy.')
357
- }
358
-
359
- // `wait` holds the full block window for the phone (fromTimeoutAction maps it
360
- // to ask, so on timeout it hands to Cursor's prompt, never auto-denies).
361
- const realMs = timeoutAction === 'wait' ? MAX_BLOCK_MS : Math.max(timeoutSeconds, 1) * 1000
562
+ const realMs = Math.max(timeoutSeconds, 1) * 1000
362
563
  const cap = Math.min(realMs, MAX_BLOCK_MS)
363
564
  const answer = await pollForAnswer(apiKey, asked.correlationId, Date.now() + cap)
364
- if (answer.answered) {
365
- if (answer.value === 'defer') return ask()
366
- return answer.value === 'yes' ? ALLOW : deny(DENIED)
367
- }
565
+ if (answer.answered) return answer.value === 'yes' ? ALLOW : deny(DENIED)
368
566
 
369
567
  // If Cursor's hook limit cut us off before the configured timeout, hand off to
370
568
  // Cursor's own prompt rather than misapplying the policy's timeout action.
@@ -382,20 +580,9 @@ const handlePushFirst = async (apiKey, command, project, ident, pushFirstSeconds
382
580
  }
383
581
  if (!asked?.correlationId) return ask()
384
582
 
385
- // Keyboard bypass / no reachable device: push_first already falls to Cursor's
386
- // prompt, so do it now rather than racing a push nobody will see.
387
- if (asked.suppressed) {
388
- await callTool(apiKey, 'cancel_question', { correlationId: asked.correlationId }).catch(() => {})
389
- return ask('You are at the keyboard — approve here.')
390
- }
391
- if (asked.noDevices) return ask('No device connected — approve here.')
392
-
393
583
  const cap = Math.min(Math.max(pushFirstSeconds, 1) * 1000, MAX_BLOCK_MS)
394
584
  const answer = await pollForAnswer(apiKey, asked.correlationId, Date.now() + cap)
395
- if (answer.answered) {
396
- if (answer.value === 'defer') return ask()
397
- return answer.value === 'yes' ? ALLOW : deny(DENIED)
398
- }
585
+ if (answer.answered) return answer.value === 'yes' ? ALLOW : deny(DENIED)
399
586
  return ask('Sent to your phone via Pushary — you can also approve here.')
400
587
  }
401
588
 
@@ -417,32 +604,16 @@ const main = async () => {
417
604
  let input
418
605
  try {
419
606
  const raw = await readStdin()
420
- if (!raw.trim()) {
421
- diag('no input on stdin. Cursor did not pipe the command to this hook (a known Cursor issue on Windows). Handing off to Cursor\'s own prompt; the push approval cannot run without the command.')
422
- return respond(ask())
423
- }
424
- // Windows: Cursor prepends a BOM/encoding prefix to the hook's stdin, so raw
425
- // arrives as e.g. "���{...}" and a bare JSON.parse throws,
426
- // silently dropping us to "ask" with no push (the script works when run
427
- // manually because there is no BOM). The payload is always a JSON object, so
428
- // parse from the first "{" — robust to a BOM or whatever prefix bytes Cursor
429
- // emits. Verified on Windows + Cursor 3.8.11.
430
- const jsonStart = raw.indexOf('{')
431
- input = JSON.parse(jsonStart > 0 ? raw.slice(jsonStart) : raw)
607
+ input = raw.trim() ? JSON.parse(raw) : {}
432
608
  } catch {
433
- diag('stdin was not valid JSON (often empty or corrupted, a known Cursor Windows stdin issue). Handing off to Cursor\'s own prompt.')
434
609
  return respond(ask())
435
610
  }
436
611
 
437
612
  const command = typeof input.command === 'string' ? input.command.trim() : ''
438
- if (!command) {
439
- diag('input had no command field. Handing off to Cursor\'s own prompt.')
440
- return respond(ask())
441
- }
613
+ if (!command) return respond(ask())
442
614
 
443
615
  const apiKey = resolveApiKey()
444
616
  if (!apiKey) {
445
- diag('no API key found (PUSHARY_API_KEY env var or embedded in the plugin mcp.json). Run: npx @pushary/agent-hooks setup --key <your key>')
446
617
  return respond(
447
618
  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.')
448
619
  )
@@ -457,7 +628,7 @@ const main = async () => {
457
628
 
458
629
  if (modeState.kill) return respond(deny('Stopped by user — this agent was halted from Pushary. Do not run this command.'))
459
630
 
460
- const tool = resolvePolicy(policy, 'Bash', modeState.mode)
631
+ const tool = resolvePolicy(policy, 'Bash', modeState.mode, command, deriveRepoKey(input.cwd))
461
632
  if (tool.timeoutSeconds === 0 && tool.timeoutAction === 'approve') return respond(ALLOW)
462
633
 
463
634
  switch (tool.mode) {
@@ -472,16 +643,19 @@ const main = async () => {
472
643
  return respond(await handlePushFirst(apiKey, command, project, ident, tool.pushFirstSeconds))
473
644
  }
474
645
  } catch (error) {
475
- process.stderr.write(`[pushary-gate] ${describeNetworkFailure(error)}\n`)
646
+ process.stderr.write(`[pushary-gate] ${error?.message ?? error}\n`)
476
647
  return respond(ask())
477
648
  }
478
649
  }
479
650
 
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
- }
651
+ // Exported for scripts/pushary-gate.test.mjs. main() only runs when the gate is
652
+ // executed directly, so importing this file for a test does not read stdin.
653
+ export { matchToolPattern, resolvePolicy, isDestructive, normalizeRepoRemote, deriveRepoKey }
654
+
655
+ const isDirectRun = process.argv[1] && import.meta.url === `file://${process.argv[1]}`
656
+ if (!isDirectRun) {
657
+ // imported for tests
658
+ } else main().catch((error) => {
659
+ process.stderr.write(`[pushary-gate] fatal: ${error?.message ?? error}\n`)
660
+ respond(ask())
661
+ })