@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.
- package/CHANGELOG.md +67 -0
- package/data/SKILL.md +14 -6
- package/data/cursor-plugin/CHANGELOG.md +27 -1
- package/data/cursor-plugin/CONTRIBUTING.md +28 -0
- package/data/cursor-plugin/README.md +1 -1
- package/data/cursor-plugin/scripts/pushary-gate.mjs +305 -131
- package/data/cursor-plugin/scripts/pushary-gate.test.mjs +130 -0
- package/data/cursor-plugin/skills/pushary/SKILL.md +14 -6
- package/data/vscode-plugin/CONTRIBUTING.md +28 -0
- package/data/vscode-plugin/skills/pushary/SKILL.md +14 -6
- package/dist/bin/pushary-claude.js +18 -3
- package/dist/bin/pushary-clean.js +5 -5
- package/dist/bin/pushary-codex-hook.js +4 -4
- package/dist/bin/pushary-codex.js +2 -2
- package/dist/bin/pushary-connect.js +3 -3
- package/dist/bin/pushary-doctor.js +7 -7
- package/dist/bin/pushary-gemini-hook.js +4 -4
- package/dist/bin/pushary-hook.js +6 -6
- package/dist/bin/pushary-login.js +2 -2
- package/dist/bin/pushary-logout.js +2 -2
- package/dist/bin/pushary-mode.js +3 -3
- package/dist/bin/pushary-notification-hook.js +2 -2
- package/dist/bin/pushary-permission-denied-hook.js +6 -6
- package/dist/bin/pushary-permission-hook.js +6 -6
- package/dist/bin/pushary-post-hook.js +2 -2
- package/dist/bin/pushary-prompt-hook.js +2 -2
- package/dist/bin/pushary-session-end-hook.js +2 -2
- package/dist/bin/pushary-session-start-hook.js +2 -2
- package/dist/bin/pushary-setup.js +17 -13
- package/dist/bin/pushary-stats.js +2 -2
- package/dist/bin/pushary-status.js +2 -2
- package/dist/bin/pushary-stop-hook.js +2 -2
- package/dist/bin/pushary-stopfailure-hook.js +2 -2
- package/dist/bin/pushary-upgrade.js +4 -4
- package/dist/bin/pushary-wait.js +3 -3
- package/dist/{chunk-Q5QV4I42.js → chunk-3CBPY2G5.js} +1 -1
- package/dist/{chunk-4S3E5DQJ.js → chunk-46UEJXQO.js} +2 -2
- package/dist/{chunk-VQSTA23N.js → chunk-7BBUTWBD.js} +1 -1
- package/dist/{chunk-HAJA47IK.js → chunk-7L57N7WN.js} +1 -1
- package/dist/{chunk-CJET52NP.js → chunk-CC2K2SST.js} +1 -1
- package/dist/{chunk-34HABKL7.js → chunk-DJHWBNM3.js} +5 -5
- package/dist/{chunk-43NDA7LR.js → chunk-DLBW37U6.js} +1 -1
- package/dist/{chunk-3BJUW2SH.js → chunk-IA5GBKNL.js} +1 -1
- package/dist/{chunk-NDCIOAL7.js → chunk-IZA7SWXX.js} +1 -1
- package/dist/{chunk-PZ4UCGWG.js → chunk-QCYN5VXB.js} +1 -1
- package/dist/{chunk-SM7EMSDQ.js → chunk-UPPHYOIZ.js} +2 -2
- package/dist/src/index.js +6 -6
- package/package.json +1 -1
- /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
|
-
|
|
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
|
|
97
|
-
if (
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
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
|
-
|
|
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
|
-
|
|
211
|
-
|
|
212
|
-
|
|
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
|
-
//
|
|
301
|
-
//
|
|
302
|
-
//
|
|
303
|
-
//
|
|
304
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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] ${
|
|
646
|
+
process.stderr.write(`[pushary-gate] ${error?.message ?? error}\n`)
|
|
476
647
|
return respond(ask())
|
|
477
648
|
}
|
|
478
649
|
}
|
|
479
650
|
|
|
480
|
-
//
|
|
481
|
-
//
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
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
|
+
})
|