@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.
- package/CHANGELOG.md +64 -0
- package/README.md +24 -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 +9 -7
- package/dist/bin/pushary-bell.js +12 -11
- package/dist/bin/pushary-claude.js +12 -11
- package/dist/bin/pushary-clean.js +8 -7
- package/dist/bin/pushary-codex-bridge.js +5 -5
- package/dist/bin/pushary-codex-hook.js +14 -20
- package/dist/bin/pushary-codex.js +3 -3
- package/dist/bin/pushary-connect.js +10 -10
- package/dist/bin/pushary-daemon.js +6 -6
- package/dist/bin/pushary-disconnect.d.ts +1 -0
- package/dist/bin/pushary-disconnect.js +125 -0
- package/dist/bin/pushary-doctor.js +16 -16
- package/dist/bin/pushary-gemini-bridge.js +5 -5
- package/dist/bin/pushary-gemini-hook.js +14 -20
- package/dist/bin/pushary-hook.js +16 -23
- package/dist/bin/pushary-login.js +6 -6
- package/dist/bin/pushary-logout.js +6 -6
- package/dist/bin/pushary-mode.js +5 -5
- package/dist/bin/pushary-notification-hook.js +8 -11
- package/dist/bin/pushary-permission-denied-hook.js +14 -12
- package/dist/bin/pushary-permission-hook.js +14 -12
- package/dist/bin/pushary-post-hook.js +8 -11
- package/dist/bin/pushary-prompt-hook.js +8 -11
- package/dist/bin/pushary-session-end-hook.js +8 -8
- package/dist/bin/pushary-session-start-hook.js +8 -8
- package/dist/bin/pushary-setup.js +29 -30
- package/dist/bin/pushary-stats.js +4 -4
- package/dist/bin/pushary-status.js +7 -7
- package/dist/bin/pushary-stop-hook.js +8 -8
- package/dist/bin/pushary-stopfailure-hook.js +8 -8
- package/dist/bin/pushary-suggestions.js +2 -2
- package/dist/bin/pushary-upgrade.js +15 -10
- package/dist/bin/pushary-wait.js +5 -5
- package/dist/bin/pushary.js +2 -1
- package/dist/{chunk-ILB6UOAF.js → chunk-55656H4Q.js} +1 -1
- package/dist/chunk-6CUUA7HO.js +59 -0
- package/dist/{chunk-6LC4WSCL.js → chunk-BZKTCOZQ.js} +1 -1
- package/dist/{chunk-K7CSRGKZ.js → chunk-CIVG746V.js} +1 -1
- package/dist/{chunk-FBGBJXVC.js → chunk-DNSIVHIA.js} +1 -1
- package/dist/{chunk-437T77TY.js → chunk-ES3FAX5X.js} +1 -1
- package/dist/chunk-EZPRY2ZY.js +70 -0
- package/dist/{chunk-MQMPG5X4.js → chunk-G3LXA2ZP.js} +207 -10
- package/dist/chunk-GM54KC62.js +187 -0
- package/dist/{chunk-MGJBC6DD.js → chunk-GOEXZ5QJ.js} +1 -0
- package/dist/chunk-HSGEEBBL.js +37 -0
- package/dist/{chunk-UXWEE3QI.js → chunk-IGV4KL7B.js} +1 -1
- package/dist/{chunk-GCKAWHFP.js → chunk-JDQY47OW.js} +1 -1
- package/dist/{chunk-7PZPJSWI.js → chunk-KTKIZEFT.js} +1 -1
- package/dist/{chunk-YR5TSPXB.js → chunk-LB6RSCLL.js} +12 -10
- package/dist/{chunk-DOXSWT4Q.js → chunk-OZKRF7MS.js} +6 -6
- package/dist/{chunk-SEAHGHCE.js → chunk-PHXEADSL.js} +40 -7
- package/dist/{chunk-F52SIIDG.js → chunk-QDKJTPAT.js} +1 -1
- package/dist/{chunk-J4KFVOCQ.js → chunk-SQOFGPNX.js} +1 -1
- package/dist/{chunk-NJ7JT26G.js → chunk-VGADRFG6.js} +1 -1
- package/dist/{chunk-HZNOAJGB.js → chunk-VKJUVPTJ.js} +2 -2
- package/dist/{chunk-ERYKGH4J.js → chunk-XIR6N66V.js} +1 -1
- package/dist/{chunk-CCH775Y6.js → chunk-XLM6COJA.js} +1 -1
- package/dist/{chunk-CCCJ6BPW.js → chunk-Y7BALRVB.js} +73 -134
- package/dist/chunk-YHG74UFF.js +12 -0
- package/dist/src/index.d.ts +7 -22
- package/dist/src/index.js +11 -8
- package/package.json +6 -3
- package/dist/chunk-BLDDXHHA.js +0 -66
- 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
|
|
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.
|