@pushary/agent-hooks 0.85.0 → 0.85.2
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 +29 -0
- package/data/cursor-plugin/CHANGELOG.md +21 -0
- package/data/cursor-plugin/scripts/pushary-gate.mjs +54 -222
- package/data/cursor-plugin/scripts/pushary-gate.test.mjs +11 -106
- package/data/vscode-plugin/CHANGELOG.md +26 -0
- package/data/vscode-plugin/scripts/pushary-gate.mjs +66 -74
- package/dist/bin/pushary-bell-hook.js +4 -4
- package/dist/bin/pushary-bell.js +6 -6
- package/dist/bin/pushary-claude.js +12 -11
- package/dist/bin/pushary-clean.js +6 -6
- package/dist/bin/pushary-codex-bridge.js +5 -5
- package/dist/bin/pushary-codex-hook.js +9 -9
- package/dist/bin/pushary-codex.js +3 -3
- package/dist/bin/pushary-connect.js +6 -6
- package/dist/bin/pushary-daemon.js +4 -4
- package/dist/bin/pushary-disconnect.js +5 -5
- package/dist/bin/pushary-doctor.js +11 -11
- package/dist/bin/pushary-gemini-bridge.js +5 -5
- package/dist/bin/pushary-gemini-hook.js +17 -12
- package/dist/bin/pushary-hook.js +8 -8
- package/dist/bin/pushary-login.js +4 -4
- package/dist/bin/pushary-logout.js +4 -4
- package/dist/bin/pushary-mode.js +3 -3
- package/dist/bin/pushary-notification-hook.js +3 -3
- package/dist/bin/pushary-permission-denied-hook.js +8 -8
- package/dist/bin/pushary-permission-hook.js +8 -8
- package/dist/bin/pushary-post-hook.js +3 -3
- package/dist/bin/pushary-prompt-hook.js +3 -3
- package/dist/bin/pushary-session-end-hook.js +3 -3
- package/dist/bin/pushary-session-start-hook.js +3 -3
- package/dist/bin/pushary-setup.js +14 -14
- package/dist/bin/pushary-stats.js +2 -2
- package/dist/bin/pushary-status.js +5 -5
- package/dist/bin/pushary-stop-hook.js +3 -3
- package/dist/bin/pushary-stopfailure-hook.js +3 -3
- package/dist/bin/pushary-upgrade.js +6 -6
- package/dist/bin/pushary-wait.js +3 -3
- package/dist/{chunk-ADISVXZL.js → chunk-4U2YRHNF.js} +1 -1
- package/dist/{chunk-WEAQQSHQ.js → chunk-4YMWLMAP.js} +1 -1
- package/dist/{chunk-437T77TY.js → chunk-CWV4CMB6.js} +1 -1
- package/dist/{chunk-EXMUM226.js → chunk-DPDUB4XF.js} +62 -156
- package/dist/{chunk-7PZPJSWI.js → chunk-GM74DUMS.js} +1 -1
- package/dist/{chunk-DOXSWT4Q.js → chunk-GY3I353G.js} +6 -6
- package/dist/chunk-HSGEEBBL.js +37 -0
- package/dist/{chunk-ERYKGH4J.js → chunk-JBLRZGTT.js} +1 -1
- package/dist/{chunk-4QPOMJUB.js → chunk-JT5NQQCE.js} +2 -2
- package/dist/{chunk-MQMPG5X4.js → chunk-K337LIBD.js} +260 -10
- package/dist/{chunk-HVZIHTOO.js → chunk-KJW6NKF7.js} +7 -7
- package/dist/{chunk-K7CSRGKZ.js → chunk-KZIGJLOX.js} +1 -1
- package/dist/{chunk-6LC4WSCL.js → chunk-LCA2EQTK.js} +1 -1
- package/dist/{chunk-F52SIIDG.js → chunk-LSB6PFLN.js} +1 -1
- package/dist/{chunk-YKW6JCWI.js → chunk-LXWISFF3.js} +1 -1
- package/dist/{chunk-FBGBJXVC.js → chunk-NTIUQB3E.js} +1 -1
- package/dist/{chunk-NJ7JT26G.js → chunk-PKUNB3SN.js} +1 -1
- package/dist/{chunk-AXRATIS6.js → chunk-Q4URN3E7.js} +1 -1
- package/dist/{chunk-GCKAWHFP.js → chunk-T54OT7IY.js} +1 -1
- package/dist/{chunk-64PXDATC.js → chunk-XJDL3MUE.js} +17 -17
- package/dist/{chunk-GFM3STWO.js → chunk-Y7E2MC3G.js} +20 -49
- package/dist/chunk-YHG74UFF.js +12 -0
- package/dist/src/index.d.ts +7 -29
- package/dist/src/index.js +10 -9
- package/package.json +4 -2
- package/dist/chunk-GXIFADGD.js +0 -66
- package/dist/chunk-PWTKKQQI.js +0 -27
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,34 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.85.2
|
|
4
|
+
|
|
5
|
+
### One agent-protocol renderer for CLI and native approvals
|
|
6
|
+
|
|
7
|
+
Claude Code, Codex and Gemini hook output now comes from the shared contracts
|
|
8
|
+
renderer also used by the native Mac gate. This keeps the CLI wire bytes unchanged
|
|
9
|
+
while preventing the native bridge and published hooks from drifting into different
|
|
10
|
+
allow or deny shapes.
|
|
11
|
+
|
|
12
|
+
## 0.85.1
|
|
13
|
+
|
|
14
|
+
### A rule you wrote is now honoured in auto mode
|
|
15
|
+
|
|
16
|
+
A policy set to deny a tool outright was computed and then discarded in the three
|
|
17
|
+
Claude Code permission modes Pushary steps aside for: `auto`, `plan` and `dontAsk`.
|
|
18
|
+
`auto` is the default on Pro, Max and Team, so this was most sessions. The native
|
|
19
|
+
classifier has never heard of your rule, so it would approve what you had forbidden.
|
|
20
|
+
|
|
21
|
+
The defer exists to stop Pushary double-prompting on top of Claude Code's own
|
|
22
|
+
prompt. A deny prompts nobody, so that reasoning never covered it. A standing deny
|
|
23
|
+
now sits with the kill switch, ahead of the defer, and applies in every mode.
|
|
24
|
+
|
|
25
|
+
Approvals are unchanged: `auto` still defers them, and a tool the classifier
|
|
26
|
+
decides to ask about is still reclaimed through `PermissionRequest` and pushed to
|
|
27
|
+
your phone. This only closes the case where a rule of yours was silently dropped.
|
|
28
|
+
|
|
29
|
+
The Codex, Gemini and SDK wrapper paths already behaved this way. This brings the
|
|
30
|
+
Claude Code path in line with them.
|
|
31
|
+
|
|
3
32
|
## 0.85.0
|
|
4
33
|
|
|
5
34
|
### A hook command that cannot outlive what it points at
|
|
@@ -1,5 +1,26 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.2.0
|
|
4
|
+
|
|
5
|
+
The gate stopped carrying its own copy of the policy engine.
|
|
6
|
+
|
|
7
|
+
Pattern matching, glob compilation, the destructive-command list and policy
|
|
8
|
+
resolution all lived in this file, roughly two hundred and fifty lines of it,
|
|
9
|
+
kept in step with the real engine by hand. That is how 0.1.1 happened: the copy
|
|
10
|
+
matched on tool name alone, so every argument rule was invisible here. It also
|
|
11
|
+
never gained the safe read-only allowlist, so `git status` reached your phone
|
|
12
|
+
from Cursor and from nowhere else.
|
|
13
|
+
|
|
14
|
+
The gate now asks the server, which runs the same engine as every other client
|
|
15
|
+
over the same policy and mode state. There is nothing left to keep in step.
|
|
16
|
+
|
|
17
|
+
- The safe read-only allowlist applies here now: ordinary read-only commands
|
|
18
|
+
stop reaching your phone.
|
|
19
|
+
- A policy change takes effect immediately. The five-minute on-disk policy cache
|
|
20
|
+
is gone, along with the stale-cache fallback.
|
|
21
|
+
- An unreachable Pushary hands the command to Cursor's own prompt, as before.
|
|
22
|
+
Nothing is ever denied because we could not reach the server.
|
|
23
|
+
|
|
3
24
|
## 0.1.1
|
|
4
25
|
|
|
5
26
|
Policy parity with the other agents. The gate matched rules on tool NAME alone, so
|
|
@@ -27,20 +27,17 @@
|
|
|
27
27
|
// it unapproved.
|
|
28
28
|
|
|
29
29
|
import { createHash } from 'node:crypto'
|
|
30
|
-
import { hostname
|
|
31
|
-
import { existsSync, readFileSync
|
|
30
|
+
import { hostname } from 'node:os'
|
|
31
|
+
import { existsSync, readFileSync } from 'node:fs'
|
|
32
32
|
import { basename, dirname, join } from 'node:path'
|
|
33
33
|
import { fileURLToPath } from 'node:url'
|
|
34
34
|
|
|
35
35
|
const BASE_URL = 'https://pushary.com'
|
|
36
36
|
const MCP_URL = `${BASE_URL}/api/mcp/mcp`
|
|
37
|
-
const POLICY_CACHE_TTL_MS = 5 * 60 * 1000
|
|
38
37
|
const MAX_BLOCK_MS = 45_000 // longest we can wait before Cursor's hook timeout
|
|
39
38
|
const WAIT_CHUNK_MS = 20_000 // per wait_for_answer long-poll
|
|
40
39
|
const POLL_GAP_MS = 1_500 // pause between polls after a transient error
|
|
41
40
|
const NET_TIMEOUT_MS = 27_000 // abort a single MCP request
|
|
42
|
-
const POLICY_TIMEOUT_MS = 10_000
|
|
43
|
-
const MODE_TIMEOUT_MS = 3_000
|
|
44
41
|
const HARD_GUARD_MS = 55_000 // force a graceful "ask" before failClosed (60s) fires
|
|
45
42
|
|
|
46
43
|
// ── Cursor decisions ──────────────────────────────────────────────────────────
|
|
@@ -229,221 +226,51 @@ const callTool = async (apiKey, name, args) => {
|
|
|
229
226
|
return JSON.parse(payload)
|
|
230
227
|
}
|
|
231
228
|
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
headers: { Authorization: `Bearer ${apiKey}` },
|
|
235
|
-
signal: AbortSignal.timeout(timeoutMs),
|
|
236
|
-
})
|
|
237
|
-
if (!response.ok) throw new Error(`GET ${path} ${response.status}`)
|
|
238
|
-
return response.json()
|
|
239
|
-
}
|
|
240
|
-
|
|
241
|
-
// ── Policy (mirrors @pushary/agent-hooks policy.ts) ──────────────────────────────
|
|
242
|
-
const isPolicyConfig = (d) =>
|
|
243
|
-
!!d && typeof d === 'object' && Array.isArray(d.policies) && typeof d.defaultTimeoutSeconds === 'number' && typeof d.defaultTimeoutAction === 'string'
|
|
244
|
-
|
|
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`)
|
|
250
|
-
|
|
251
|
-
const getPolicy = async (apiKey) => {
|
|
252
|
-
const path = policyCacheFile(apiKey)
|
|
253
|
-
let stale = null
|
|
254
|
-
if (existsSync(path)) {
|
|
255
|
-
try {
|
|
256
|
-
const cached = JSON.parse(readFileSync(path, 'utf-8'))
|
|
257
|
-
if (isPolicyConfig(cached)) {
|
|
258
|
-
if (!cached._cachedAt || Date.now() - cached._cachedAt < POLICY_CACHE_TTL_MS) return cached
|
|
259
|
-
stale = cached
|
|
260
|
-
}
|
|
261
|
-
} catch {}
|
|
262
|
-
}
|
|
263
|
-
try {
|
|
264
|
-
const fresh = await withRetry(async () => {
|
|
265
|
-
const raw = await getJson('/api/mcp/policy?repoAware=1', apiKey, POLICY_TIMEOUT_MS)
|
|
266
|
-
if (!isPolicyConfig(raw)) throw new Error('invalid policy')
|
|
267
|
-
return raw
|
|
268
|
-
}, 2)
|
|
269
|
-
try {
|
|
270
|
-
writeFileSync(path, JSON.stringify({ ...fresh, _cachedAt: Date.now() }), 'utf-8')
|
|
271
|
-
} catch {}
|
|
272
|
-
return fresh
|
|
273
|
-
} catch (error) {
|
|
274
|
-
if (stale) return stale
|
|
275
|
-
throw error
|
|
276
|
-
}
|
|
277
|
-
}
|
|
278
|
-
|
|
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.
|
|
229
|
+
// ── The verdict ─────────────────────────────────────────────────────────────
|
|
230
|
+
// Asked for, not worked out here.
|
|
284
231
|
//
|
|
285
|
-
//
|
|
286
|
-
//
|
|
287
|
-
//
|
|
288
|
-
//
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
//
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
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 ??
|
|
413
|
-
config.policies.find((p) => p.tool === '*') ??
|
|
414
|
-
{
|
|
415
|
-
tool: toolName,
|
|
416
|
-
timeoutSeconds: config.defaultTimeoutSeconds,
|
|
417
|
-
timeoutAction: config.defaultTimeoutAction,
|
|
418
|
-
mode: config.defaultMode ?? 'push_first',
|
|
419
|
-
pushFirstSeconds: config.defaultPushFirstSeconds ?? 20,
|
|
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
|
-
|
|
434
|
-
const effective = modeOverride ?? config.modeOverride
|
|
435
|
-
return effective ? { ...base, mode: effective } : base
|
|
436
|
-
}
|
|
437
|
-
|
|
438
|
-
const APPROVAL_MODES = ['push_only', 'terminal_only', 'push_first', 'notify_only']
|
|
439
|
-
const fetchModeState = async (apiKey, sessionId) => {
|
|
232
|
+
// This file used to carry its own copy of the authorization boundary: pattern
|
|
233
|
+
// matching, glob compilation, a destructive-command list and policy resolution,
|
|
234
|
+
// roughly two hundred and fifty lines of it. A copy of a rule is a rule that
|
|
235
|
+
// drifts, and this one had already drifted twice. It matched on tool name alone
|
|
236
|
+
// once, so a user's own `Bash(rm:*)` deny did nothing while a broad `Bash` allow
|
|
237
|
+
// approved `rm -rf`. And it never gained `isSafeReadOnlyCommand`, so `git status`
|
|
238
|
+
// reached the phone here and nowhere else.
|
|
239
|
+
//
|
|
240
|
+
// The server now answers with the same `resolveGate` the CLI runs, over the same
|
|
241
|
+
// state, so there is one boundary and this asks it. What stays local is only
|
|
242
|
+
// what a server cannot know: the repository this checkout is in, which needs a
|
|
243
|
+
// parent walk and a git config read on this machine.
|
|
244
|
+
|
|
245
|
+
const GATE_TIMEOUT_MS = 5000
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* The verdict for one command, or null if we could not get one.
|
|
249
|
+
*
|
|
250
|
+
* Null is not a denial and not an approval: every failure here hands the call
|
|
251
|
+
* back to Cursor's own prompt, exactly as if this gate were not installed.
|
|
252
|
+
*/
|
|
253
|
+
const decide = async (apiKey, command, cwd, sessionId) => {
|
|
440
254
|
try {
|
|
441
|
-
const
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
255
|
+
const response = await fetch(`${BASE_URL}/api/agent/gate`, {
|
|
256
|
+
method: 'POST',
|
|
257
|
+
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${apiKey}` },
|
|
258
|
+
body: JSON.stringify({
|
|
259
|
+
v: 1,
|
|
260
|
+
source: 'cursor',
|
|
261
|
+
toolName: 'Bash',
|
|
262
|
+
toolInputs: [{ command }],
|
|
263
|
+
cwd,
|
|
264
|
+
repoKey: deriveRepoKey(cwd),
|
|
265
|
+
sessionId,
|
|
266
|
+
}),
|
|
267
|
+
signal: AbortSignal.timeout(GATE_TIMEOUT_MS),
|
|
268
|
+
})
|
|
269
|
+
if (!response.ok) return null
|
|
270
|
+
const verdict = await response.json()
|
|
271
|
+
return verdict && typeof verdict.kind === 'string' ? verdict : null
|
|
445
272
|
} catch {
|
|
446
|
-
return
|
|
273
|
+
return null
|
|
447
274
|
}
|
|
448
275
|
}
|
|
449
276
|
|
|
@@ -624,12 +451,17 @@ const main = async () => {
|
|
|
624
451
|
const ident = { agentName: `Cursor - ${project}`, sessionId, machineId: getMachineId() }
|
|
625
452
|
|
|
626
453
|
try {
|
|
627
|
-
const
|
|
454
|
+
const verdict = await decide(apiKey, command, input.cwd, sessionId)
|
|
628
455
|
|
|
629
|
-
|
|
456
|
+
// No verdict, or one that says nothing: Cursor's own prompt decides, exactly
|
|
457
|
+
// as if this gate were not installed. Never a forced denial on an outage.
|
|
458
|
+
if (!verdict || verdict.kind === 'no_opinion') return respond(ask())
|
|
459
|
+
if (verdict.kind === 'kill') return respond(deny(verdict.reason))
|
|
460
|
+
if (verdict.kind === 'allow') return respond(ALLOW)
|
|
461
|
+
if (verdict.kind === 'deny') return respond(deny(verdict.reason))
|
|
462
|
+
if (verdict.kind !== 'ask') return respond(ask())
|
|
630
463
|
|
|
631
|
-
const tool =
|
|
632
|
-
if (tool.timeoutSeconds === 0 && tool.timeoutAction === 'approve') return respond(ALLOW)
|
|
464
|
+
const tool = verdict.policy
|
|
633
465
|
|
|
634
466
|
switch (tool.mode) {
|
|
635
467
|
case 'terminal_only':
|
|
@@ -650,7 +482,7 @@ const main = async () => {
|
|
|
650
482
|
|
|
651
483
|
// Exported for scripts/pushary-gate.test.mjs. main() only runs when the gate is
|
|
652
484
|
// executed directly, so importing this file for a test does not read stdin.
|
|
653
|
-
export {
|
|
485
|
+
export { normalizeRepoRemote, deriveRepoKey }
|
|
654
486
|
|
|
655
487
|
const isDirectRun = process.argv[1] && import.meta.url === `file://${process.argv[1]}`
|
|
656
488
|
if (!isDirectRun) {
|
|
@@ -1,115 +1,20 @@
|
|
|
1
|
-
//
|
|
1
|
+
// What is left to test in the gate itself.
|
|
2
2
|
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
3
|
+
// This file used to cover the vendored policy engine: pattern matching, argument
|
|
4
|
+
// rules, the destructive ceiling and repo scoping. None of that lives here any
|
|
5
|
+
// more. The gate asks POST /api/agent/gate, which runs the same `resolveGate` as
|
|
6
|
+
// every other client, and those behaviours are tested once against the engine in
|
|
7
|
+
// packages/contracts/src/gate.test.ts rather than once per copy.
|
|
8
|
+
//
|
|
9
|
+
// What remains is the one thing a server cannot answer: which repository this
|
|
10
|
+
// checkout is in. Deriving it means walking parent directories for a `.git` and
|
|
11
|
+
// reading its config, so the gate still does it and sends the result.
|
|
7
12
|
//
|
|
8
13
|
// Run: node --test scripts/pushary-gate.test.mjs
|
|
9
14
|
|
|
10
15
|
import assert from 'node:assert/strict'
|
|
11
16
|
import { describe, it } from 'node:test'
|
|
12
|
-
import {
|
|
13
|
-
|
|
14
|
-
const base = {
|
|
15
|
-
defaultTimeoutSeconds: 60,
|
|
16
|
-
defaultTimeoutAction: 'escalate',
|
|
17
|
-
defaultMode: 'push_first',
|
|
18
|
-
defaultPushFirstSeconds: 20,
|
|
19
|
-
}
|
|
20
|
-
const cfg = (...policies) => ({ ...base, policies })
|
|
21
|
-
const allow = { timeoutSeconds: 0, timeoutAction: 'approve', mode: 'terminal_only', pushFirstSeconds: 0 }
|
|
22
|
-
const denyRule = { timeoutSeconds: 0, timeoutAction: 'deny', mode: 'push_only', pushFirstSeconds: 0 }
|
|
23
|
-
const autoApproves = (p) => p.timeoutSeconds === 0 && p.timeoutAction === 'approve'
|
|
24
|
-
|
|
25
|
-
describe('matchToolPattern', () => {
|
|
26
|
-
it('matches bare, exact and prefix forms', () => {
|
|
27
|
-
assert.equal(matchToolPattern('Bash', 'Bash', 'npm test'), 'tool')
|
|
28
|
-
assert.equal(matchToolPattern('Bash(git status)', 'Bash', 'git status'), 'exact')
|
|
29
|
-
assert.equal(matchToolPattern('Bash(npm test:*)', 'Bash', 'npm test -- --watch'), 'prefix')
|
|
30
|
-
assert.equal(matchToolPattern('Bash(npm test:*)', 'Bash', 'npm install'), 'none')
|
|
31
|
-
})
|
|
32
|
-
|
|
33
|
-
it('checks :* before globs so destructive prefixes keep their meaning', () => {
|
|
34
|
-
assert.equal(matchToolPattern('Bash(rm -rf:*)', 'Bash', 'rm -rf /tmp/x'), 'prefix')
|
|
35
|
-
})
|
|
36
|
-
|
|
37
|
-
it('supports globs', () => {
|
|
38
|
-
assert.equal(matchToolPattern('Bash(deploy *)', 'Bash', 'deploy staging'), 'prefix')
|
|
39
|
-
assert.equal(matchToolPattern('Bash(deploy *)', 'Bash', 'deploy'), 'none')
|
|
40
|
-
})
|
|
41
|
-
|
|
42
|
-
it('does not backtrack on adversarial star patterns', () => {
|
|
43
|
-
const started = Date.now()
|
|
44
|
-
assert.equal(matchToolPattern(`Bash(${'*'.repeat(8)}x)`, 'Bash', 'a'.repeat(4000)), 'none')
|
|
45
|
-
assert.ok(Date.now() - started < 1000, 'must not backtrack')
|
|
46
|
-
})
|
|
47
|
-
})
|
|
48
|
-
|
|
49
|
-
describe('argument rules, which the gate previously ignored entirely', () => {
|
|
50
|
-
it("honours a user's explicit deny under a broad allow", () => {
|
|
51
|
-
const config = cfg({ tool: 'Bash', ...allow }, { tool: 'Bash(rm:*)', ...denyRule })
|
|
52
|
-
const resolved = resolvePolicy(config, 'Bash', null, 'rm -rf /important', undefined)
|
|
53
|
-
assert.equal(resolved.timeoutAction, 'deny')
|
|
54
|
-
assert.equal(autoApproves(resolved), false)
|
|
55
|
-
})
|
|
56
|
-
|
|
57
|
-
it('honours the preset always-ask rule for force pushes', () => {
|
|
58
|
-
const config = cfg({ tool: 'Bash', ...allow }, { tool: 'Bash(git push:*)', timeoutSeconds: 60, timeoutAction: 'wait', mode: 'push_first', pushFirstSeconds: 20 })
|
|
59
|
-
assert.equal(resolvePolicy(config, 'Bash', null, 'git push --force origin main', undefined).timeoutAction, 'wait')
|
|
60
|
-
})
|
|
61
|
-
|
|
62
|
-
it('prefers an exact rule over a matching prefix rule', () => {
|
|
63
|
-
const config = cfg({ tool: 'Bash(git:*)', ...denyRule }, { tool: 'Bash(git status)', ...allow })
|
|
64
|
-
assert.equal(autoApproves(resolvePolicy(config, 'Bash', null, 'git status', undefined)), true)
|
|
65
|
-
})
|
|
66
|
-
})
|
|
67
|
-
|
|
68
|
-
describe('destructive ceiling', () => {
|
|
69
|
-
it('refuses to auto-approve a destructive command through a bare-tool allow', () => {
|
|
70
|
-
const resolved = resolvePolicy(cfg({ tool: 'Bash', ...allow }), 'Bash', null, 'rm -rf /important', undefined)
|
|
71
|
-
assert.equal(autoApproves(resolved), false)
|
|
72
|
-
assert.equal(resolved.timeoutAction, 'wait')
|
|
73
|
-
})
|
|
74
|
-
|
|
75
|
-
it('refuses through a wildcard allow too', () => {
|
|
76
|
-
assert.equal(autoApproves(resolvePolicy(cfg({ tool: '*', ...allow }), 'Bash', null, 'git push --force origin main', undefined)), false)
|
|
77
|
-
})
|
|
78
|
-
|
|
79
|
-
it('still lets an explicit rule the user wrote win', () => {
|
|
80
|
-
const config = cfg({ tool: 'Bash', ...allow }, { tool: 'Bash(rm -rf:*)', ...allow })
|
|
81
|
-
assert.equal(autoApproves(resolvePolicy(config, 'Bash', null, 'rm -rf /tmp/x', undefined)), true)
|
|
82
|
-
})
|
|
83
|
-
|
|
84
|
-
it('leaves ordinary commands alone', () => {
|
|
85
|
-
assert.equal(autoApproves(resolvePolicy(cfg({ tool: 'Bash', ...allow }), 'Bash', null, 'npm test', undefined)), true)
|
|
86
|
-
})
|
|
87
|
-
|
|
88
|
-
it('flags the classifier set', () => {
|
|
89
|
-
for (const c of ['rm -rf /', 'sudo reboot', 'git reset --hard', 'terraform destroy', 'npm publish', 'DROP TABLE users']) {
|
|
90
|
-
assert.equal(isDestructive(c), true, c)
|
|
91
|
-
}
|
|
92
|
-
assert.equal(isDestructive('npm test'), false)
|
|
93
|
-
})
|
|
94
|
-
})
|
|
95
|
-
|
|
96
|
-
describe('repo scoping', () => {
|
|
97
|
-
const scoped = { tool: 'Bash', repoKey: 'github.com/acme/api', ...denyRule }
|
|
98
|
-
|
|
99
|
-
it('applies a scoped rule only in its own repository', () => {
|
|
100
|
-
assert.equal(resolvePolicy(cfg(scoped), 'Bash', null, 'npm test', 'github.com/acme/api').timeoutAction, 'deny')
|
|
101
|
-
assert.notEqual(resolvePolicy(cfg(scoped), 'Bash', null, 'npm test', 'github.com/acme/web').timeoutAction, 'deny')
|
|
102
|
-
})
|
|
103
|
-
|
|
104
|
-
it('never applies a scoped rule when the repository is unknown', () => {
|
|
105
|
-
assert.notEqual(resolvePolicy(cfg(scoped), 'Bash', null, 'npm test', undefined).timeoutAction, 'deny')
|
|
106
|
-
})
|
|
107
|
-
|
|
108
|
-
it('prefers the scoped rule at equal rank', () => {
|
|
109
|
-
const config = cfg({ tool: 'Bash', ...allow }, scoped)
|
|
110
|
-
assert.equal(resolvePolicy(config, 'Bash', null, 'npm test', 'github.com/acme/api').timeoutAction, 'deny')
|
|
111
|
-
})
|
|
112
|
-
})
|
|
17
|
+
import { normalizeRepoRemote } from './pushary-gate.mjs'
|
|
113
18
|
|
|
114
19
|
describe('repo identity', () => {
|
|
115
20
|
it('collapses the forms of one remote', () => {
|
|
@@ -1,5 +1,31 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.2.0
|
|
4
|
+
|
|
5
|
+
The gate stopped carrying its own copy of the policy engine, and two defects went
|
|
6
|
+
with it.
|
|
7
|
+
|
|
8
|
+
It matched rules on tool NAME alone, so every argument rule was invisible: a
|
|
9
|
+
`Bash(rm:*)` deny you had written did nothing here, and `rm -rf` was auto-approved
|
|
10
|
+
by a broad `Bash` allow. The same bug was found and fixed in the Cursor gate in
|
|
11
|
+
its 0.1.1; this copy still had it. It also had no safe read-only allowlist, so
|
|
12
|
+
`git status` reached your phone.
|
|
13
|
+
|
|
14
|
+
Its mode fetch read an unreachable server as "not halted", which is not the same
|
|
15
|
+
thing as "we could not ask". A remote stop that could not be fetched looked like
|
|
16
|
+
a clean bill of health.
|
|
17
|
+
|
|
18
|
+
The gate now asks the server, which runs the same engine as every other client
|
|
19
|
+
over the same state, and which distinguishes state it could not establish from
|
|
20
|
+
state that says no.
|
|
21
|
+
|
|
22
|
+
- Argument rules apply, with the same precedence every other agent uses.
|
|
23
|
+
- The destructive ceiling applies: a command the classifier flags can no longer
|
|
24
|
+
be auto-approved through a broad rule.
|
|
25
|
+
- A policy change takes effect immediately; the on-disk policy cache is gone.
|
|
26
|
+
- An unreachable Pushary hands the command to VS Code's own prompt. Nothing is
|
|
27
|
+
ever denied because we could not reach the server.
|
|
28
|
+
|
|
3
29
|
## 0.1.0
|
|
4
30
|
|
|
5
31
|
First release.
|