@pushary/agent-hooks 0.68.0 → 0.69.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +89 -0
- package/data/cursor-plugin/scripts/pushary-gate.mjs +36 -10
- package/data/vscode-plugin/scripts/pushary-gate.mjs +32 -9
- package/dist/bin/pushary-claude.js +21 -11
- package/dist/bin/pushary-clean.js +58 -20
- package/dist/bin/pushary-codex-hook.js +38 -23
- package/dist/bin/pushary-codex.js +1 -1
- package/dist/bin/pushary-connect.js +1 -1
- package/dist/bin/pushary-doctor.js +139 -26
- package/dist/bin/pushary-gemini-hook.js +32 -16
- package/dist/bin/pushary-hook.js +3 -3
- package/dist/bin/pushary-notification-hook.js +1 -1
- package/dist/bin/pushary-permission-denied-hook.js +3 -3
- package/dist/bin/pushary-permission-hook.js +3 -3
- package/dist/bin/pushary-post-hook.js +1 -1
- package/dist/bin/pushary-prompt-hook.js +1 -1
- package/dist/bin/pushary-session-end-hook.js +1 -1
- package/dist/bin/pushary-session-start-hook.js +1 -1
- package/dist/bin/pushary-setup.js +16 -6
- package/dist/bin/pushary-status.js +1 -1
- package/dist/bin/pushary-stop-hook.js +1 -1
- package/dist/bin/pushary-stopfailure-hook.js +1 -1
- package/dist/chunk-3USMXNVB.js +64 -0
- package/dist/{chunk-SONXGCZY.js → chunk-C5I2RAMT.js} +45 -27
- package/dist/{chunk-XOUYM27W.js → chunk-H46LSQRT.js} +182 -103
- package/dist/{chunk-KB4ODLGB.js → chunk-I4BYZIX4.js} +2 -0
- package/dist/{chunk-YMFZUXL5.js → chunk-P7VBAYQO.js} +25 -0
- package/dist/src/index.d.ts +1 -0
- package/dist/src/index.js +3 -3
- package/package.json +1 -1
- package/dist/chunk-YHG74UFF.js +0 -12
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,94 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.69.0
|
|
4
|
+
|
|
5
|
+
### An approval is never granted on a state Pushary could not confirm
|
|
6
|
+
|
|
7
|
+
When the hook could not reach Pushary, it read that as "you have not been
|
|
8
|
+
stopped, and you are under no agreed scope". Those are answers, and not reaching
|
|
9
|
+
the server is not an answer. A rule you had set to approve automatically could
|
|
10
|
+
therefore fire during an outage on exactly the kind of call a live scope would
|
|
11
|
+
have held back.
|
|
12
|
+
|
|
13
|
+
It now tells the two apart. If the last successful check was under a minute ago,
|
|
14
|
+
that answer still stands, so a brief network blip changes nothing, and a stop you
|
|
15
|
+
issued moments earlier still stops the agent. Past that, an automatic approval is
|
|
16
|
+
withheld and the decision goes to your agent's own prompt instead. Read only
|
|
17
|
+
shell commands are unaffected, because that list is decided on your machine and
|
|
18
|
+
needs no server at all.
|
|
19
|
+
|
|
20
|
+
A rejected key is treated as a real answer rather than a blip, so a key you
|
|
21
|
+
revoked stops being honoured at once.
|
|
22
|
+
|
|
23
|
+
### A scope you agreed to is now honoured everywhere, not just in one place
|
|
24
|
+
|
|
25
|
+
Scope was checked when Claude Code asked before running a tool, and skipped on
|
|
26
|
+
four other paths that could reach the same decision: Claude's permission dialog,
|
|
27
|
+
Codex, Gemini CLI, and the remote wrapper. Each of them fetched the scope you had
|
|
28
|
+
agreed to and then ignored it, so the same edit could be waved through depending
|
|
29
|
+
only on which route it arrived by. All five now run the same check in the same
|
|
30
|
+
order.
|
|
31
|
+
|
|
32
|
+
### Codex patches are judged on every file they touch
|
|
33
|
+
|
|
34
|
+
A Codex patch that changes several files at once was judged on the patch as a
|
|
35
|
+
whole, so a rule you wrote for one file did not apply when that file was part of
|
|
36
|
+
a larger change. Every file is looked at now, and the strictest rule wins. Rules
|
|
37
|
+
written as paths also work for Codex and Gemini, which they quietly did not
|
|
38
|
+
before.
|
|
39
|
+
|
|
40
|
+
### Command details that could contain a secret no longer leave your machine
|
|
41
|
+
|
|
42
|
+
A command like `GH_TOKEN=... gh pr create` carries its credential in the first
|
|
43
|
+
few words, and those words were being sent and stored as the label for what the
|
|
44
|
+
agent wanted to do. Notification text was not being cleaned at all. Both are
|
|
45
|
+
cleaned now, on your machine and again on arrival, and the Cursor and VS Code
|
|
46
|
+
gates clean the command before it is sent rather than after.
|
|
47
|
+
|
|
48
|
+
### setup --dry-run really does change nothing
|
|
49
|
+
|
|
50
|
+
On a machine with no key, a dry run reached the pairing step, drew a QR code,
|
|
51
|
+
waited for your phone, created a real API key, and then said nothing had been
|
|
52
|
+
written. It now stops before anything is created and tells you a real run would
|
|
53
|
+
connect a key first.
|
|
54
|
+
|
|
55
|
+
### clean can no longer leave Cursor blocking your commands
|
|
56
|
+
|
|
57
|
+
Cursor is told to block a matched command if the Pushary gate cannot answer, and
|
|
58
|
+
that list includes ordinary work like rebase, migrate, deploy and publish. Clean
|
|
59
|
+
removed the gate's files before removing that instruction, so if the second step
|
|
60
|
+
failed, Cursor was left blocking all of them on a file that no longer existed,
|
|
61
|
+
under a message saying clean had finished. The order is reversed and checked, and
|
|
62
|
+
if the instruction cannot be removed the files stay put, clean says so, and it
|
|
63
|
+
exits with an error.
|
|
64
|
+
|
|
65
|
+
### Pairing survives a dropped reply
|
|
66
|
+
|
|
67
|
+
If the reply carrying your key was lost in transit, the terminal reported that
|
|
68
|
+
pairing had expired and you started again, while the key it never received stayed
|
|
69
|
+
on your account. The key is now held until your terminal confirms it has it, so
|
|
70
|
+
retrying finishes the pairing you already started. A terminal that cannot reach
|
|
71
|
+
Pushary at all now says so instead of sitting under a QR code for fifteen
|
|
72
|
+
minutes.
|
|
73
|
+
|
|
74
|
+
### doctor reports on the agents you actually use
|
|
75
|
+
|
|
76
|
+
Doctor checked Claude Code whether or not you had it, so setting Pushary up for
|
|
77
|
+
Codex alone produced a column of failures about software you had never installed.
|
|
78
|
+
It now reports on the agents Pushary is set up for, and says plainly when it is
|
|
79
|
+
set up for none.
|
|
80
|
+
|
|
81
|
+
Three things it used to call healthy, it no longer does. A key that lives only in
|
|
82
|
+
a shell profile cannot be read by any hook, so an install where nothing could
|
|
83
|
+
work was passing every check. A key saved inside Claude, Cursor or VS Code that
|
|
84
|
+
no longer matches the one in use, which happens whenever pairing issues a new
|
|
85
|
+
one, went unmentioned. And when doctor could not reach Pushary at all, it said
|
|
86
|
+
everything was fine.
|
|
87
|
+
|
|
88
|
+
Its exit codes now distinguish a broken setup from having no phone connected from
|
|
89
|
+
not being able to reach Pushary, so a script can tell what went wrong. A test
|
|
90
|
+
question nobody answers is now withdrawn instead of sitting on your phone.
|
|
91
|
+
|
|
3
92
|
## 0.67.0
|
|
4
93
|
|
|
5
94
|
### The VS Code agent can now ask for approval on your phone
|
|
@@ -32,7 +32,10 @@ import { existsSync, readFileSync, writeFileSync } from 'node:fs'
|
|
|
32
32
|
import { basename, dirname, join } from 'node:path'
|
|
33
33
|
import { fileURLToPath } from 'node:url'
|
|
34
34
|
|
|
35
|
-
|
|
35
|
+
// Overridable like every other Pushary client (the VS Code gate already was).
|
|
36
|
+
// Hardcoded, this gate could not be pointed at a staging or self-hosted server,
|
|
37
|
+
// so there was no way to exercise it anywhere but production.
|
|
38
|
+
const BASE_URL = process.env.PUSHARY_BASE_URL?.trim() || process.env.PUSHARY_API_URL?.trim() || 'https://pushary.com'
|
|
36
39
|
const MCP_URL = `${BASE_URL}/api/mcp/mcp`
|
|
37
40
|
const POLICY_CACHE_TTL_MS = 5 * 60 * 1000
|
|
38
41
|
const MAX_BLOCK_MS = 45_000 // longest we can wait before Cursor's hook timeout
|
|
@@ -235,24 +238,47 @@ const fetchModeState = async (apiKey, sessionId) => {
|
|
|
235
238
|
// dependency-free hook cannot import the workspace) ──────────────────────────────
|
|
236
239
|
const ACTION_BODY_MAX = 4000
|
|
237
240
|
const ACTION_BODY_TRUNCATION_MARKER = '\n… [truncated]'
|
|
241
|
+
// Two tiers, mirroring SECRET_REDACTION_RULES in @pushary/contracts. The precise
|
|
242
|
+
// rules only match real credential shapes, so they are safe on a line a human
|
|
243
|
+
// reads: a git SHA, a path and prose all survive. The high-entropy catch-all
|
|
244
|
+
// over-redacts by design and is therefore only ever applied to a full body dump.
|
|
245
|
+
//
|
|
246
|
+
// One combined list used to serve both, which meant the only text this gate
|
|
247
|
+
// scrubbed was the action body. The question and the notification body carried
|
|
248
|
+
// the raw command.
|
|
238
249
|
const REDACTION_RULES = [
|
|
239
|
-
[
|
|
240
|
-
[/\
|
|
241
|
-
[/\
|
|
250
|
+
[/-----BEGIN[A-Z0-9 ]*PRIVATE KEY-----[\s\S]*?-----END[A-Z0-9 ]*PRIVATE KEY-----/g, '[redacted key]'],
|
|
251
|
+
[/\bsk-[A-Za-z0-9_-]{16,}\b/g, '[redacted]'],
|
|
252
|
+
[/\b[spr]k_(?:live|test)_[A-Za-z0-9]{8,}\b/g, '[redacted]'],
|
|
253
|
+
[/\bwhsec_[A-Za-z0-9]{16,}\b/g, '[redacted]'],
|
|
254
|
+
[/\b(?:ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9]{36,}\b/g, '[redacted]'],
|
|
255
|
+
[/\bgithub_pat_[A-Za-z0-9_]{22,}\b/g, '[redacted]'],
|
|
256
|
+
[/\bglpat-[A-Za-z0-9_-]{20,}\b/g, '[redacted]'],
|
|
257
|
+
[/\bxox[baprs]-[A-Za-z0-9-]{10,}\b/g, '[redacted]'],
|
|
258
|
+
[/\bAIza[A-Za-z0-9_-]{35}\b/g, '[redacted]'],
|
|
242
259
|
[/\bAKIA[0-9A-Z]{16}\b/g, '[redacted]'],
|
|
243
|
-
[/\
|
|
260
|
+
[/\bnpm_[A-Za-z0-9]{36}\b/g, '[redacted]'],
|
|
261
|
+
[/\bxai-[A-Za-z0-9]{16,}\b/g, '[redacted]'],
|
|
262
|
+
[/\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\b/g, '[redacted]'],
|
|
263
|
+
[/\bbearer\s+[A-Za-z0-9._~+/=-]+/gi, 'bearer [redacted]'],
|
|
244
264
|
[/\bauthorization:\s*\S+/gi, 'authorization: [redacted]'],
|
|
245
|
-
[/((?:secret|token|password|passwd|api[_-]?key|private[_-]?key)\s*[=:]\s*)(
|
|
246
|
-
[/[A-Za-z0-9+/]{40,}={0,2}/g, '[redacted]'],
|
|
265
|
+
[/((?:secret|token|password|passwd|api[_-]?key|access[_-]?key|client[_-]?secret|private[_-]?key)\s*[=:]\s*)("[^"]*"|'[^']*'|\S+)/gi, '$1[redacted]'],
|
|
247
266
|
]
|
|
267
|
+
const HIGH_ENTROPY_RULE = [/[A-Za-z0-9+/]{40,}={0,2}/g, '[redacted]']
|
|
248
268
|
const redactSecrets = (text) => REDACTION_RULES.reduce((acc, [pattern, replacement]) => acc.replace(pattern, replacement), text)
|
|
269
|
+
const redactSecretsDeep = (text) => redactSecrets(text).replace(HIGH_ENTROPY_RULE[0], HIGH_ENTROPY_RULE[1])
|
|
249
270
|
const capActionBody = (text) =>
|
|
250
271
|
text.length <= ACTION_BODY_MAX ? text : `${text.slice(0, ACTION_BODY_MAX - ACTION_BODY_TRUNCATION_MARKER.length)}${ACTION_BODY_TRUNCATION_MARKER}`
|
|
251
|
-
const deriveActionBody = (command) => capActionBody(
|
|
272
|
+
const deriveActionBody = (command) => capActionBody(redactSecretsDeep(command))
|
|
252
273
|
|
|
253
274
|
// ── ask / wait ───────────────────────────────────────────────────────────────
|
|
275
|
+
// Redacted, not raw. This is the text a human reads on a lock screen and in
|
|
276
|
+
// Slack, and it used to be the command verbatim: `curl -H "Authorization:
|
|
277
|
+
// Bearer ..."` left the machine and landed in the question. The server scrubs
|
|
278
|
+
// this field too, but a credential should never travel to be scrubbed on
|
|
279
|
+
// arrival, and the notify body below was scrubbed nowhere at all.
|
|
254
280
|
const askArgs = (command, project, ident) => ({
|
|
255
|
-
question: `Allow this command?\n\n${command}`,
|
|
281
|
+
question: `Allow this command?\n\n${redactSecrets(command)}`,
|
|
256
282
|
type: 'confirm',
|
|
257
283
|
context: `Cursor agent wants to run this in ${project}`,
|
|
258
284
|
agentName: ident.agentName,
|
|
@@ -353,7 +379,7 @@ const handleNotifyOnly = async (apiKey, command, project, ident) => {
|
|
|
353
379
|
try {
|
|
354
380
|
await callTool(apiKey, 'send_notification', {
|
|
355
381
|
title: 'Agent needs approval',
|
|
356
|
-
body: command.slice(0, 180),
|
|
382
|
+
body: redactSecrets(command).slice(0, 180),
|
|
357
383
|
agentName: ident.agentName,
|
|
358
384
|
sessionId: ident.sessionId,
|
|
359
385
|
machineId: ident.machineId,
|
|
@@ -291,20 +291,38 @@ const fetchModeState = async (apiKey, sessionId) => {
|
|
|
291
291
|
// dependency-free hook cannot import the workspace) ───────────────────────────
|
|
292
292
|
const ACTION_BODY_MAX = 4000
|
|
293
293
|
const ACTION_BODY_TRUNCATION_MARKER = '\n… [truncated]'
|
|
294
|
+
// Two tiers, mirroring SECRET_REDACTION_RULES in @pushary/contracts. The precise
|
|
295
|
+
// rules only match real credential shapes, so they are safe on a line a human
|
|
296
|
+
// reads: a git SHA, a path and prose all survive. The high-entropy catch-all
|
|
297
|
+
// over-redacts by design and is therefore only ever applied to a full body dump.
|
|
298
|
+
//
|
|
299
|
+
// One combined list used to serve both, which meant the only text this gate
|
|
300
|
+
// scrubbed was the action body. The question and the notification body carried
|
|
301
|
+
// the raw command.
|
|
294
302
|
const REDACTION_RULES = [
|
|
295
|
-
[
|
|
296
|
-
[/\
|
|
297
|
-
[/\
|
|
303
|
+
[/-----BEGIN[A-Z0-9 ]*PRIVATE KEY-----[\s\S]*?-----END[A-Z0-9 ]*PRIVATE KEY-----/g, '[redacted key]'],
|
|
304
|
+
[/\bsk-[A-Za-z0-9_-]{16,}\b/g, '[redacted]'],
|
|
305
|
+
[/\b[spr]k_(?:live|test)_[A-Za-z0-9]{8,}\b/g, '[redacted]'],
|
|
306
|
+
[/\bwhsec_[A-Za-z0-9]{16,}\b/g, '[redacted]'],
|
|
307
|
+
[/\b(?:ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9]{36,}\b/g, '[redacted]'],
|
|
308
|
+
[/\bgithub_pat_[A-Za-z0-9_]{22,}\b/g, '[redacted]'],
|
|
309
|
+
[/\bglpat-[A-Za-z0-9_-]{20,}\b/g, '[redacted]'],
|
|
310
|
+
[/\bxox[baprs]-[A-Za-z0-9-]{10,}\b/g, '[redacted]'],
|
|
311
|
+
[/\bAIza[A-Za-z0-9_-]{35}\b/g, '[redacted]'],
|
|
298
312
|
[/\bAKIA[0-9A-Z]{16}\b/g, '[redacted]'],
|
|
299
|
-
[/\
|
|
313
|
+
[/\bnpm_[A-Za-z0-9]{36}\b/g, '[redacted]'],
|
|
314
|
+
[/\bxai-[A-Za-z0-9]{16,}\b/g, '[redacted]'],
|
|
315
|
+
[/\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\b/g, '[redacted]'],
|
|
316
|
+
[/\bbearer\s+[A-Za-z0-9._~+/=-]+/gi, 'bearer [redacted]'],
|
|
300
317
|
[/\bauthorization:\s*\S+/gi, 'authorization: [redacted]'],
|
|
301
|
-
[/((?:secret|token|password|passwd|api[_-]?key|private[_-]?key)\s*[=:]\s*)(
|
|
302
|
-
[/[A-Za-z0-9+/]{40,}={0,2}/g, '[redacted]'],
|
|
318
|
+
[/((?:secret|token|password|passwd|api[_-]?key|access[_-]?key|client[_-]?secret|private[_-]?key)\s*[=:]\s*)("[^"]*"|'[^']*'|\S+)/gi, '$1[redacted]'],
|
|
303
319
|
]
|
|
320
|
+
const HIGH_ENTROPY_RULE = [/[A-Za-z0-9+/]{40,}={0,2}/g, '[redacted]']
|
|
304
321
|
const redactSecrets = (text) => REDACTION_RULES.reduce((acc, [pattern, replacement]) => acc.replace(pattern, replacement), text)
|
|
322
|
+
const redactSecretsDeep = (text) => redactSecrets(text).replace(HIGH_ENTROPY_RULE[0], HIGH_ENTROPY_RULE[1])
|
|
305
323
|
const capActionBody = (text) =>
|
|
306
324
|
text.length <= ACTION_BODY_MAX ? text : `${text.slice(0, ACTION_BODY_MAX - ACTION_BODY_TRUNCATION_MARKER.length)}${ACTION_BODY_TRUNCATION_MARKER}`
|
|
307
|
-
const deriveActionBody = (command) => capActionBody(
|
|
325
|
+
const deriveActionBody = (command) => capActionBody(redactSecretsDeep(command))
|
|
308
326
|
|
|
309
327
|
// VS Code's terminal tool has used more than one field name for the command, and
|
|
310
328
|
// a plain `tool_input` object is not guaranteed. Read the known spellings and
|
|
@@ -328,8 +346,13 @@ export const shouldGate = (toolName, toolInput) => {
|
|
|
328
346
|
}
|
|
329
347
|
|
|
330
348
|
// ── ask / wait ────────────────────────────────────────────────────────────────
|
|
349
|
+
// Redacted, not raw. This is the text a human reads on a lock screen and in
|
|
350
|
+
// Slack, and it used to be the command verbatim: `curl -H "Authorization:
|
|
351
|
+
// Bearer ..."` left the machine and landed in the question. The server scrubs
|
|
352
|
+
// this field too, but a credential should never travel to be scrubbed on
|
|
353
|
+
// arrival, and the notify body below was scrubbed nowhere at all.
|
|
331
354
|
const askArgs = (command, project, ident) => ({
|
|
332
|
-
question: `Allow this command?\n\n${command}`,
|
|
355
|
+
question: `Allow this command?\n\n${redactSecrets(command)}`,
|
|
333
356
|
type: 'confirm',
|
|
334
357
|
context: `VS Code agent wants to run this in ${project}`,
|
|
335
358
|
agentName: ident.agentName,
|
|
@@ -426,7 +449,7 @@ const handleNotifyOnly = async (apiKey, command, project, ident) => {
|
|
|
426
449
|
try {
|
|
427
450
|
await callTool(apiKey, 'send_notification', {
|
|
428
451
|
title: 'Agent needs approval',
|
|
429
|
-
body: command.slice(0, 180),
|
|
452
|
+
body: redactSecrets(command).slice(0, 180),
|
|
430
453
|
agentName: ident.agentName,
|
|
431
454
|
sessionId: ident.sessionId,
|
|
432
455
|
machineId: ident.machineId,
|
|
@@ -4,8 +4,10 @@ import {
|
|
|
4
4
|
spawnClaude
|
|
5
5
|
} from "../chunk-XHKBHWLX.js";
|
|
6
6
|
import {
|
|
7
|
-
|
|
8
|
-
|
|
7
|
+
KILL_REASON,
|
|
8
|
+
isDeferAnswer,
|
|
9
|
+
resolveGate
|
|
10
|
+
} from "../chunk-3USMXNVB.js";
|
|
9
11
|
import {
|
|
10
12
|
askUser,
|
|
11
13
|
cancelQuestion,
|
|
@@ -15,9 +17,9 @@ import {
|
|
|
15
17
|
getPolicy,
|
|
16
18
|
repoKeyFor,
|
|
17
19
|
reportEvent,
|
|
18
|
-
|
|
20
|
+
scopePathFor,
|
|
19
21
|
waitForAnswer
|
|
20
|
-
} from "../chunk-
|
|
22
|
+
} from "../chunk-H46LSQRT.js";
|
|
21
23
|
import "../chunk-BSZYIAZL.js";
|
|
22
24
|
import "../chunk-SAF6HGAA.js";
|
|
23
25
|
import "../chunk-7QLSKOSU.js";
|
|
@@ -470,7 +472,6 @@ var deny = (message) => ({ behavior: "deny", message });
|
|
|
470
472
|
var createRemoteApprover = (deps) => {
|
|
471
473
|
const fetchModeState2 = deps.fetchModeState ?? fetchModeState;
|
|
472
474
|
const getPolicy2 = deps.getPolicy ?? getPolicy;
|
|
473
|
-
const resolvePolicy2 = deps.resolvePolicy ?? resolvePolicy;
|
|
474
475
|
const askUser2 = deps.askUser ?? askUser;
|
|
475
476
|
const waitForAnswer2 = deps.waitForAnswer ?? waitForAnswer;
|
|
476
477
|
const cancelQuestion2 = deps.cancelQuestion ?? cancelQuestion;
|
|
@@ -531,13 +532,22 @@ var createRemoteApprover = (deps) => {
|
|
|
531
532
|
try {
|
|
532
533
|
const sessionId = deps.getSessionId?.();
|
|
533
534
|
const modeState = await fetchModeState2(deps.apiKey, sessionId);
|
|
534
|
-
if (modeState.kill) return deny("Stopped by user \u2014 this agent was halted from Pushary");
|
|
535
535
|
const policyConfig = await getPolicy2(deps.apiKey, modeState.policyVersion, "claude_code", true);
|
|
536
|
-
const
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
536
|
+
const scopePath = modeState.scope ? scopePathFor(toolName, input, deps.cwd) : void 0;
|
|
537
|
+
const verdict = resolveGate({
|
|
538
|
+
modeState,
|
|
539
|
+
config: policyConfig,
|
|
540
|
+
toolName,
|
|
541
|
+
toolInputs: [input],
|
|
542
|
+
scopePaths: scopePath ? [scopePath] : [],
|
|
543
|
+
cwd: deps.cwd,
|
|
544
|
+
repoKey: repoKeyFor(deps.cwd)
|
|
545
|
+
});
|
|
546
|
+
if (verdict.kind === "kill") return deny(KILL_REASON);
|
|
547
|
+
if (verdict.kind === "allow") return allow(input);
|
|
548
|
+
if (verdict.kind === "deny") return deny(verdict.reason);
|
|
549
|
+
if (verdict.kind === "defer") return deny("Approval state unavailable; denied by default");
|
|
550
|
+
const policy = verdict.policy;
|
|
541
551
|
const description = describeToolCall(toolName, input, "hook");
|
|
542
552
|
const toolTarget = deriveToolTarget(toolName, input);
|
|
543
553
|
let question;
|
|
@@ -8,7 +8,7 @@ import {
|
|
|
8
8
|
shortenHome,
|
|
9
9
|
unregisterPluginLocation,
|
|
10
10
|
vscodeSettingsTargets
|
|
11
|
-
} from "../chunk-
|
|
11
|
+
} from "../chunk-I4BYZIX4.js";
|
|
12
12
|
import {
|
|
13
13
|
removeGeminiSettings
|
|
14
14
|
} from "../chunk-E2U35RLD.js";
|
|
@@ -81,8 +81,11 @@ var dim = (s) => `\x1B[2m${s}\x1B[0m`;
|
|
|
81
81
|
var bold = (s) => `\x1B[1m${s}\x1B[0m`;
|
|
82
82
|
var green = (s) => `\x1B[32m${s}\x1B[0m`;
|
|
83
83
|
var yellow = (s) => `\x1B[33m${s}\x1B[0m`;
|
|
84
|
+
var red = (s) => `\x1B[31m${s}\x1B[0m`;
|
|
84
85
|
var check = green("\u2713");
|
|
85
86
|
var skip = yellow("\u2013");
|
|
87
|
+
var cross = red("\u2717");
|
|
88
|
+
var incompleteClean = false;
|
|
86
89
|
var CLAUDE_SETTINGS = claudeSettings();
|
|
87
90
|
var CLAUDE_SETTINGS_LOCAL = claudeSettingsLocal();
|
|
88
91
|
var CLAUDE_JSON = claudeJson();
|
|
@@ -141,6 +144,39 @@ var guardedAliasRemoval = () => {
|
|
|
141
144
|
var writeJson = (path, data) => {
|
|
142
145
|
guardedWrite(path, JSON.stringify(data, null, 2) + "\n");
|
|
143
146
|
};
|
|
147
|
+
var removeCursorUserHook = () => {
|
|
148
|
+
if (!existsSync(CURSOR_USER_HOOKS)) return "absent";
|
|
149
|
+
let parsed;
|
|
150
|
+
try {
|
|
151
|
+
parsed = JSON.parse(readFileSync(CURSOR_USER_HOOKS, "utf-8"));
|
|
152
|
+
} catch {
|
|
153
|
+
return "failed";
|
|
154
|
+
}
|
|
155
|
+
if (!parsed || typeof parsed !== "object") return "failed";
|
|
156
|
+
const hooks = parsed.hooks ?? {};
|
|
157
|
+
const existing = Array.isArray(hooks.beforeShellExecution) ? hooks.beforeShellExecution : [];
|
|
158
|
+
const others = existing.filter((h) => !String(h.command ?? "").includes("pushary-gate"));
|
|
159
|
+
if (others.length === existing.length) return "absent";
|
|
160
|
+
if (others.length === 0) delete hooks.beforeShellExecution;
|
|
161
|
+
else hooks.beforeShellExecution = others;
|
|
162
|
+
parsed.hooks = hooks;
|
|
163
|
+
try {
|
|
164
|
+
writeJson(CURSOR_USER_HOOKS, parsed);
|
|
165
|
+
} catch {
|
|
166
|
+
return "failed";
|
|
167
|
+
}
|
|
168
|
+
if (dryRun) return "removed";
|
|
169
|
+
try {
|
|
170
|
+
const after = JSON.parse(readFileSync(CURSOR_USER_HOOKS, "utf-8"));
|
|
171
|
+
const stillThere = after?.hooks?.beforeShellExecution ?? [];
|
|
172
|
+
if (Array.isArray(stillThere) && stillThere.some((h) => String(h.command ?? "").includes("pushary-gate"))) {
|
|
173
|
+
return "failed";
|
|
174
|
+
}
|
|
175
|
+
} catch {
|
|
176
|
+
return "failed";
|
|
177
|
+
}
|
|
178
|
+
return "removed";
|
|
179
|
+
};
|
|
144
180
|
var cleanSettingsFile = (path, label) => {
|
|
145
181
|
const data = readJson(path);
|
|
146
182
|
if (!data) {
|
|
@@ -212,29 +248,25 @@ var main = async () => {
|
|
|
212
248
|
} else {
|
|
213
249
|
console.log(` ${skip} Cursor MCP config ${dim("(not found)")}`);
|
|
214
250
|
}
|
|
215
|
-
|
|
251
|
+
const cursorGate = removeCursorUserHook();
|
|
252
|
+
if (cursorGate === "removed") {
|
|
253
|
+
console.log(` ${check} Cursor gate ${dim("(removed from ~/.cursor/hooks.json)")}`);
|
|
254
|
+
} else if (cursorGate === "absent") {
|
|
255
|
+
console.log(` ${skip} Cursor gate ${dim("(not registered)")}`);
|
|
256
|
+
} else {
|
|
257
|
+
incompleteClean = true;
|
|
258
|
+
console.log(` ${cross} Cursor gate ${dim("(could not read or write ~/.cursor/hooks.json)")}`);
|
|
259
|
+
console.log(` ${yellow("!")} The gate is registered failClosed, so matched commands would be blocked.`);
|
|
260
|
+
console.log(` ${dim("Keeping the plugin in place so Cursor keeps working. Fix the file, then re-run clean.")}`);
|
|
261
|
+
}
|
|
262
|
+
if (cursorGate === "failed") {
|
|
263
|
+
console.log(` ${skip} Cursor plugin ${dim("(kept: removing it now would block matched commands)")}`);
|
|
264
|
+
} else if (existsSync(CURSOR_PLUGIN_DIR)) {
|
|
216
265
|
guardedRemove(CURSOR_PLUGIN_DIR);
|
|
217
266
|
console.log(` ${check} Cursor plugin ${dim("(removed from ~/.cursor/plugins/local)")}`);
|
|
218
267
|
} else {
|
|
219
268
|
console.log(` ${skip} Cursor plugin ${dim("(not installed)")}`);
|
|
220
269
|
}
|
|
221
|
-
const cursorHooks = readJson(CURSOR_USER_HOOKS);
|
|
222
|
-
if (cursorHooks) {
|
|
223
|
-
const hooks = cursorHooks.hooks ?? {};
|
|
224
|
-
const existing = Array.isArray(hooks.beforeShellExecution) ? hooks.beforeShellExecution : [];
|
|
225
|
-
const others = existing.filter((h) => !String(h.command ?? "").includes("pushary-gate"));
|
|
226
|
-
if (others.length !== existing.length) {
|
|
227
|
-
if (others.length === 0) delete hooks.beforeShellExecution;
|
|
228
|
-
else hooks.beforeShellExecution = others;
|
|
229
|
-
cursorHooks.hooks = hooks;
|
|
230
|
-
writeJson(CURSOR_USER_HOOKS, cursorHooks);
|
|
231
|
-
console.log(` ${check} Cursor gate ${dim("(removed from ~/.cursor/hooks.json)")}`);
|
|
232
|
-
} else {
|
|
233
|
-
console.log(` ${skip} Cursor gate ${dim("(no pushary entries)")}`);
|
|
234
|
-
}
|
|
235
|
-
} else {
|
|
236
|
-
console.log(` ${skip} Cursor gate ${dim("(no hooks.json)")}`);
|
|
237
|
-
}
|
|
238
270
|
for (const settingsPath of vscodeSettingsTargets(existsSync)) {
|
|
239
271
|
const label = `VS Code plugin ${dim(`(${shortenHome(settingsPath)})`)}`;
|
|
240
272
|
if (!existsSync(settingsPath)) {
|
|
@@ -401,7 +433,12 @@ var main = async () => {
|
|
|
401
433
|
console.log(` ${skip} Global package ${dim("(not installed)")}`);
|
|
402
434
|
}
|
|
403
435
|
console.log();
|
|
404
|
-
|
|
436
|
+
if (incompleteClean) {
|
|
437
|
+
console.log(` ${yellow(bold("Clean finished with something left behind."))}`);
|
|
438
|
+
console.log(` ${dim("See the \u2717 above. Fix it, then re-run")} npx @pushary/agent-hooks@latest clean`);
|
|
439
|
+
} else {
|
|
440
|
+
console.log(` ${green(bold("Clean complete."))}`);
|
|
441
|
+
}
|
|
405
442
|
console.log(` ${dim("Run")} npx @pushary/agent-hooks@latest setup ${dim("to reinstall.")}`);
|
|
406
443
|
if (aliasRemovedFrom.length > 0) {
|
|
407
444
|
console.log();
|
|
@@ -411,5 +448,6 @@ var main = async () => {
|
|
|
411
448
|
}
|
|
412
449
|
console.log(` ${dim("If a")} ${bold("pushary claude")} ${dim("or")} ${bold("pushary daemon")} ${dim("is still running anywhere, stop it (Ctrl-C).")}`);
|
|
413
450
|
console.log();
|
|
451
|
+
if (incompleteClean) process.exit(EXIT.FAILED);
|
|
414
452
|
};
|
|
415
453
|
main();
|
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import {
|
|
3
|
+
KILL_REASON,
|
|
3
4
|
denyReasonFrom,
|
|
4
|
-
isDeferAnswer
|
|
5
|
-
|
|
5
|
+
isDeferAnswer,
|
|
6
|
+
resolveGate
|
|
7
|
+
} from "../chunk-3USMXNVB.js";
|
|
6
8
|
import {
|
|
7
9
|
CODEX_AGENT,
|
|
8
10
|
DEFAULT_SESSION,
|
|
@@ -24,14 +26,16 @@ import {
|
|
|
24
26
|
permissionTimeoutDecision,
|
|
25
27
|
preToolUseTimeoutDecision,
|
|
26
28
|
readLastPrompt,
|
|
29
|
+
repoKeyFor,
|
|
27
30
|
reportEvent,
|
|
28
|
-
resolvePolicy,
|
|
29
31
|
savePendingQuestion,
|
|
32
|
+
scopePathFor,
|
|
30
33
|
sendNotification,
|
|
31
34
|
toCodexWire,
|
|
32
35
|
toPolicyLookup,
|
|
36
|
+
toPolicyLookups,
|
|
33
37
|
waitForAnswer
|
|
34
|
-
} from "../chunk-
|
|
38
|
+
} from "../chunk-H46LSQRT.js";
|
|
35
39
|
import {
|
|
36
40
|
isGatingMoment,
|
|
37
41
|
recordKeylessMoment
|
|
@@ -57,7 +61,6 @@ import "../chunk-KZERVKTD.js";
|
|
|
57
61
|
import { basename, join } from "path";
|
|
58
62
|
import { tmpdir, userInfo } from "os";
|
|
59
63
|
import { existsSync, mkdirSync, statSync, unlinkSync, writeFileSync } from "fs";
|
|
60
|
-
var KILL_REASON = "Stopped by user: this agent was halted from Pushary";
|
|
61
64
|
var START_MS = Date.now();
|
|
62
65
|
var APPROVAL_TTL_MS = 10 * 60 * 1e3;
|
|
63
66
|
var sanitizeId = (value) => value.replace(/[^A-Za-z0-9_-]/g, "_").slice(0, 128);
|
|
@@ -167,18 +170,33 @@ var notifyApprovalNeeded = async (apiKey, input, lookup) => {
|
|
|
167
170
|
} catch {
|
|
168
171
|
}
|
|
169
172
|
};
|
|
173
|
+
var codexGate = async (apiKey, input) => {
|
|
174
|
+
const modeState = await fetchModeState(apiKey, input.session_id);
|
|
175
|
+
const lookup = toPolicyLookup(input.tool_name ?? "", input.tool_input ?? {}, input.cwd);
|
|
176
|
+
const lookups = toPolicyLookups(input.tool_name ?? "", input.tool_input ?? {}, input.cwd);
|
|
177
|
+
const config = await getPolicy(apiKey, modeState.policyVersion, "codex");
|
|
178
|
+
const verdict = resolveGate({
|
|
179
|
+
modeState,
|
|
180
|
+
config,
|
|
181
|
+
toolName: lookups.tool,
|
|
182
|
+
toolInputs: lookups.inputs,
|
|
183
|
+
// Skipped entirely without a contract, which is the state of nearly every
|
|
184
|
+
// session. A multi-file patch contributes one path per file.
|
|
185
|
+
scopePaths: modeState.scope ? lookups.inputs.map((entry) => scopePathFor(lookups.tool, entry, input.cwd)).filter((path) => typeof path === "string") : [],
|
|
186
|
+
cwd: input.cwd,
|
|
187
|
+
repoKey: repoKeyFor(input.cwd)
|
|
188
|
+
});
|
|
189
|
+
return { verdict, lookup };
|
|
190
|
+
};
|
|
170
191
|
var decidePermissionRequest = async (input) => {
|
|
171
192
|
try {
|
|
172
193
|
const apiKey = getApiKey();
|
|
173
|
-
const
|
|
174
|
-
if (
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
if (toolPolicy.timeoutSeconds === 0 && toolPolicy.timeoutAction === "deny") {
|
|
180
|
-
return codexDeny(`Denied by policy for ${toolPolicy.tool}`);
|
|
181
|
-
}
|
|
194
|
+
const { verdict, lookup } = await codexGate(apiKey, input);
|
|
195
|
+
if (verdict.kind === "kill") return codexDeny(KILL_REASON);
|
|
196
|
+
if (verdict.kind === "allow") return codexAllow();
|
|
197
|
+
if (verdict.kind === "deny") return codexDeny(verdict.reason);
|
|
198
|
+
if (verdict.kind === "defer") return codexPass();
|
|
199
|
+
const toolPolicy = verdict.policy;
|
|
182
200
|
if (toolPolicy.mode === "terminal_only") return codexPass();
|
|
183
201
|
if (toolPolicy.mode === "notify_only") {
|
|
184
202
|
if (claimNotify(input.tool_use_id)) await notifyApprovalNeeded(apiKey, input, lookup);
|
|
@@ -229,15 +247,12 @@ var decidePreToolUse = async (input) => {
|
|
|
229
247
|
return codexPass();
|
|
230
248
|
}
|
|
231
249
|
try {
|
|
232
|
-
const
|
|
233
|
-
if (
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
if (toolPolicy.timeoutSeconds === 0 && toolPolicy.timeoutAction === "deny") {
|
|
239
|
-
return codexDeny(`Denied by policy for ${toolPolicy.tool}`);
|
|
240
|
-
}
|
|
250
|
+
const { verdict, lookup } = await codexGate(apiKey, input);
|
|
251
|
+
if (verdict.kind === "kill") return codexDeny(KILL_REASON);
|
|
252
|
+
if (verdict.kind === "allow") return codexPass();
|
|
253
|
+
if (verdict.kind === "deny") return codexDeny(verdict.reason);
|
|
254
|
+
if (verdict.kind === "defer") return codexPass();
|
|
255
|
+
const toolPolicy = verdict.policy;
|
|
241
256
|
if (toolPolicy.mode === "push_only") {
|
|
242
257
|
if (consumeApproval(input.tool_use_id)) return codexPass();
|
|
243
258
|
let pushed;
|