@pushary/agent-hooks 0.84.1 → 0.85.1

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 (71) hide show
  1. package/CHANGELOG.md +64 -0
  2. package/README.md +24 -0
  3. package/data/cursor-plugin/CHANGELOG.md +21 -0
  4. package/data/cursor-plugin/scripts/pushary-gate.mjs +54 -222
  5. package/data/cursor-plugin/scripts/pushary-gate.test.mjs +11 -106
  6. package/data/vscode-plugin/CHANGELOG.md +26 -0
  7. package/data/vscode-plugin/scripts/pushary-gate.mjs +66 -74
  8. package/dist/bin/pushary-bell-hook.js +9 -7
  9. package/dist/bin/pushary-bell.js +12 -11
  10. package/dist/bin/pushary-claude.js +12 -11
  11. package/dist/bin/pushary-clean.js +8 -7
  12. package/dist/bin/pushary-codex-bridge.js +5 -5
  13. package/dist/bin/pushary-codex-hook.js +14 -20
  14. package/dist/bin/pushary-codex.js +3 -3
  15. package/dist/bin/pushary-connect.js +10 -10
  16. package/dist/bin/pushary-daemon.js +6 -6
  17. package/dist/bin/pushary-disconnect.d.ts +1 -0
  18. package/dist/bin/pushary-disconnect.js +125 -0
  19. package/dist/bin/pushary-doctor.js +16 -16
  20. package/dist/bin/pushary-gemini-bridge.js +5 -5
  21. package/dist/bin/pushary-gemini-hook.js +14 -20
  22. package/dist/bin/pushary-hook.js +16 -23
  23. package/dist/bin/pushary-login.js +6 -6
  24. package/dist/bin/pushary-logout.js +6 -6
  25. package/dist/bin/pushary-mode.js +5 -5
  26. package/dist/bin/pushary-notification-hook.js +8 -11
  27. package/dist/bin/pushary-permission-denied-hook.js +14 -12
  28. package/dist/bin/pushary-permission-hook.js +14 -12
  29. package/dist/bin/pushary-post-hook.js +8 -11
  30. package/dist/bin/pushary-prompt-hook.js +8 -11
  31. package/dist/bin/pushary-session-end-hook.js +8 -8
  32. package/dist/bin/pushary-session-start-hook.js +8 -8
  33. package/dist/bin/pushary-setup.js +29 -30
  34. package/dist/bin/pushary-stats.js +4 -4
  35. package/dist/bin/pushary-status.js +7 -7
  36. package/dist/bin/pushary-stop-hook.js +8 -8
  37. package/dist/bin/pushary-stopfailure-hook.js +8 -8
  38. package/dist/bin/pushary-suggestions.js +2 -2
  39. package/dist/bin/pushary-upgrade.js +15 -10
  40. package/dist/bin/pushary-wait.js +5 -5
  41. package/dist/bin/pushary.js +2 -1
  42. package/dist/{chunk-ILB6UOAF.js → chunk-55656H4Q.js} +1 -1
  43. package/dist/chunk-6CUUA7HO.js +59 -0
  44. package/dist/{chunk-6LC4WSCL.js → chunk-BZKTCOZQ.js} +1 -1
  45. package/dist/{chunk-K7CSRGKZ.js → chunk-CIVG746V.js} +1 -1
  46. package/dist/{chunk-FBGBJXVC.js → chunk-DNSIVHIA.js} +1 -1
  47. package/dist/{chunk-437T77TY.js → chunk-ES3FAX5X.js} +1 -1
  48. package/dist/chunk-EZPRY2ZY.js +70 -0
  49. package/dist/{chunk-MQMPG5X4.js → chunk-G3LXA2ZP.js} +207 -10
  50. package/dist/chunk-GM54KC62.js +187 -0
  51. package/dist/{chunk-MGJBC6DD.js → chunk-GOEXZ5QJ.js} +1 -0
  52. package/dist/chunk-HSGEEBBL.js +37 -0
  53. package/dist/{chunk-UXWEE3QI.js → chunk-IGV4KL7B.js} +1 -1
  54. package/dist/{chunk-GCKAWHFP.js → chunk-JDQY47OW.js} +1 -1
  55. package/dist/{chunk-7PZPJSWI.js → chunk-KTKIZEFT.js} +1 -1
  56. package/dist/{chunk-YR5TSPXB.js → chunk-LB6RSCLL.js} +12 -10
  57. package/dist/{chunk-DOXSWT4Q.js → chunk-OZKRF7MS.js} +6 -6
  58. package/dist/{chunk-SEAHGHCE.js → chunk-PHXEADSL.js} +40 -7
  59. package/dist/{chunk-F52SIIDG.js → chunk-QDKJTPAT.js} +1 -1
  60. package/dist/{chunk-J4KFVOCQ.js → chunk-SQOFGPNX.js} +1 -1
  61. package/dist/{chunk-NJ7JT26G.js → chunk-VGADRFG6.js} +1 -1
  62. package/dist/{chunk-HZNOAJGB.js → chunk-VKJUVPTJ.js} +2 -2
  63. package/dist/{chunk-ERYKGH4J.js → chunk-XIR6N66V.js} +1 -1
  64. package/dist/{chunk-CCH775Y6.js → chunk-XLM6COJA.js} +1 -1
  65. package/dist/{chunk-CCCJ6BPW.js → chunk-Y7BALRVB.js} +73 -134
  66. package/dist/chunk-YHG74UFF.js +12 -0
  67. package/dist/src/index.d.ts +7 -22
  68. package/dist/src/index.js +11 -8
  69. package/package.json +6 -3
  70. package/dist/chunk-BLDDXHHA.js +0 -66
  71. package/dist/chunk-UKSQWTBH.js +0 -208
package/CHANGELOG.md CHANGED
@@ -1,5 +1,69 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.85.1
4
+
5
+ ### A rule you wrote is now honoured in auto mode
6
+
7
+ A policy set to deny a tool outright was computed and then discarded in the three
8
+ Claude Code permission modes Pushary steps aside for: `auto`, `plan` and `dontAsk`.
9
+ `auto` is the default on Pro, Max and Team, so this was most sessions. The native
10
+ classifier has never heard of your rule, so it would approve what you had forbidden.
11
+
12
+ The defer exists to stop Pushary double-prompting on top of Claude Code's own
13
+ prompt. A deny prompts nobody, so that reasoning never covered it. A standing deny
14
+ now sits with the kill switch, ahead of the defer, and applies in every mode.
15
+
16
+ Approvals are unchanged: `auto` still defers them, and a tool the classifier
17
+ decides to ask about is still reclaimed through `PermissionRequest` and pushed to
18
+ your phone. This only closes the case where a rule of yours was silently dropped.
19
+
20
+ The Codex, Gemini and SDK wrapper paths already behaved this way. This brings the
21
+ Claude Code path in line with them.
22
+
23
+ ## 0.85.0
24
+
25
+ ### A hook command that cannot outlive what it points at
26
+
27
+ Every hook command written into `~/.claude/settings.json`, `~/.codex/hooks.json`
28
+ and `~/.gemini/settings.json`, the free bell included, is now wrapped so that a missing binary, a removed
29
+ `node`, a changed npm prefix or a moved home directory exits 0 and the agent reads
30
+ "no opinion". Before, any of those turned every tool call into a failing hook.
31
+ The guard is tested on every shell that can be `/bin/sh`, including dash, and with
32
+ `node` off PATH, which used to exit 127 and print on every tool call.
33
+
34
+ Codex hashes the exact command string to trust a hook, so `pushary upgrade` rewrites
35
+ the trust entry when it re-applies the hooks.
36
+
37
+ ### Hooks registered for the CLI you have
38
+
39
+ Claude Code events are registered from a table with a version floor per event.
40
+ `SubagentStart`, `SubagentStop`, `PreCompact`, `PostCompact` and `TeammateIdle`
41
+ are added on a CLI that understands them, and `pushary upgrade` re-registers
42
+ against the CLI you have now. Disconnect removes every event in the table; a
43
+ hand-typed list used to leave the five observability events behind.
44
+
45
+ ### `pushary disconnect <agent>`
46
+
47
+ Removes the MCP server, the hooks, the skill and, for Codex, the trust entries and
48
+ the API key from `config.toml`, for one agent, leaving the others alone. The bin
49
+ map entry was written in a shape the build silently dropped, and the command read
50
+ the dispatcher's own token as the agent name; both are fixed before this, its
51
+ first release, and a test now holds every bin entry to the one shape tsup builds.
52
+
53
+ ### A kill switch per hook path
54
+
55
+ `~/.pushary/admission-rules.json` (`{"version":1,"disabled":["claude:PostToolUse"]}`)
56
+ silences one event on one agent, or one agent entirely, without a release. Every
57
+ hook binary reads its input through this check, from the same file the macOS
58
+ helper reads, so one file governs both transports. Deny-only: nothing in the file
59
+ can enable a hook.
60
+
61
+ ### `PostToolUse` no longer pays two round trips per tool call
62
+
63
+ The telemetry hooks reuse the mode state the gate fetched for the same session
64
+ within the last 60 seconds. The gate itself still reads it live, so a remote stop
65
+ and a scope contract are never served stale where they decide anything.
66
+
3
67
  ## 0.84.1
4
68
 
5
69
  ### Remote starts survive lost acknowledgements without double-launching
package/README.md CHANGED
@@ -108,6 +108,29 @@ npx @pushary/agent-hooks@latest doctor
108
108
 
109
109
  Any other MCP client can use notify and ask by pointing at the Pushary MCP endpoint directly.
110
110
 
111
+ ## Hook events, gated by version
112
+
113
+ Claude Code 2.1.239 names thirty-one hook events and the set grows release by release,
114
+ so the ones we register are gated on the version actually installed. The version is
115
+ read from disk and never by running the binary: `codex --version` once submitted the
116
+ Codex CLI to XProtect, which killed it and moved the 181 MB binary to the Trash, so
117
+ setup deleted the user's installation as a side effect of asking its version.
118
+
119
+ An unreadable version registers only the events that predate gating. Each registration
120
+ records `registered|<version>`, which is what lets an upgrade pick up newer events
121
+ without anyone re-running setup.
122
+
123
+ ## Admission rules
124
+
125
+ Publishing this package is a version bump merged to main, and a hook change is not
126
+ live until that happens, so a hook that floods on one event would otherwise need a
127
+ release to stop. `~/.pushary/admission-rules.json` can disable an `(agent, event)`
128
+ pair as data.
129
+
130
+ Deny-only by construction: a rule can silence a hook we ship, and no shape of the file
131
+ can enable one we do not. A file that cannot be read admits everything, because
132
+ failing closed would let one malformed download silence every hook on the machine.
133
+
111
134
  ## Commands
112
135
 
113
136
  <!-- Managed: every command below is declared in src/cli/commands.ts, and a test
@@ -123,6 +146,7 @@ logout Remove the key this machine stores, and say what it does not remove
123
146
  connect Connect a phone without re-running setup (--app for the Pushary app)
124
147
  bell Free local bell when an agent finishes or needs you. No account, no key, no network
125
148
  doctor Verify your Pushary installation is working
149
+ disconnect Turn off one agent (claude|codex|gemini|cursor)
126
150
  clean Remove all Pushary configuration (--yes for non-interactive)
127
151
  mode Switch approval mode (push_only, push_first, terminal_only)
128
152
  wait Show or set the "wait for your phone" ladder (pushary wait 45)
@@ -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, tmpdir } from 'node:os'
31
- import { existsSync, readFileSync, writeFileSync } from 'node:fs'
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
- const getJson = async (path, apiKey, timeoutMs) => {
233
- const response = await fetch(`${BASE_URL}${path}`, {
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
- // 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 ??
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 path = sessionId ? `/api/mcp/mode?session=${encodeURIComponent(sessionId)}` : '/api/mcp/mode'
442
- const data = await getJson(path, apiKey, MODE_TIMEOUT_MS)
443
- const mode = data?.override?.mode
444
- return { mode: APPROVAL_MODES.includes(mode) ? mode : null, kill: data?.kill === true }
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 { mode: null, kill: false }
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 [policy, modeState] = await Promise.all([getPolicy(apiKey), fetchModeState(apiKey, sessionId)])
454
+ const verdict = await decide(apiKey, command, input.cwd, sessionId)
628
455
 
629
- if (modeState.kill) return respond(deny('Stopped by user this agent was halted from Pushary. Do not run this command.'))
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 = resolvePolicy(policy, 'Bash', modeState.mode, command, deriveRepoKey(input.cwd))
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 { matchToolPattern, resolvePolicy, isDestructive, normalizeRepoRemote, deriveRepoKey }
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
- // Parity tests for the vendored policy logic in pushary-gate.mjs.
1
+ // What is left to test in the gate itself.
2
2
  //
3
- // The gate cannot depend on @pushary/contracts a Cursor plugin is cloned, not
4
- // installed so the matcher is vendored. These cases mirror policy.test.ts in
5
- // the Pushary monorepo. If they drift, Cursor silently resolves a different
6
- // policy from every other agent, which is the bug this file exists to prevent.
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 { matchToolPattern, resolvePolicy, isDestructive, normalizeRepoRemote } from './pushary-gate.mjs'
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.