@pushary/agent-hooks 0.67.1 → 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 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
- const BASE_URL = 'https://pushary.com'
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
- [/\bsk-[A-Za-z0-9]{20,}\b/g, '[redacted]'],
240
- [/\bpk_(?:live|test)_[A-Za-z0-9]+\b/g, '[redacted]'],
241
- [/\brk_[A-Za-z0-9]+\b/g, '[redacted]'],
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
- [/\bbearer\s+[A-Za-z0-9._-]+/gi, 'bearer [redacted]'],
260
+ [/\bnpm_[A-Za-z0-9]{36}\b/g, '[redacted]'],
261
+ [/\bxai-[A-Za-z0-9]{16,}\b/g, '[redacted]'],
262
+ [/\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\b/g, '[redacted]'],
263
+ [/\bbearer\s+[A-Za-z0-9._~+/=-]+/gi, 'bearer [redacted]'],
244
264
  [/\bauthorization:\s*\S+/gi, 'authorization: [redacted]'],
245
- [/((?:secret|token|password|passwd|api[_-]?key|private[_-]?key)\s*[=:]\s*)(\S+)/gi, '$1[redacted]'],
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(redactSecrets(command))
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
- [/\bsk-[A-Za-z0-9]{20,}\b/g, '[redacted]'],
296
- [/\bpk_(?:live|test)_[A-Za-z0-9]+\b/g, '[redacted]'],
297
- [/\brk_[A-Za-z0-9]+\b/g, '[redacted]'],
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
- [/\bbearer\s+[A-Za-z0-9._-]+/gi, 'bearer [redacted]'],
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*)(\S+)/gi, '$1[redacted]'],
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(redactSecrets(command))
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
- isDeferAnswer
8
- } from "../chunk-YHG74UFF.js";
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
- resolvePolicy,
20
+ scopePathFor,
19
21
  waitForAnswer
20
- } from "../chunk-XOUYM27W.js";
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 policy = resolvePolicy2(policyConfig, toolName, modeState.mode, input, deps.cwd, repoKeyFor(deps.cwd));
537
- if (policy.timeoutSeconds === 0 && policy.timeoutAction === "approve") return allow(input);
538
- if (policy.timeoutSeconds === 0 && policy.timeoutAction === "deny") {
539
- return deny(`Denied by policy for ${policy.tool}`);
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-KB4ODLGB.js";
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
- if (existsSync(CURSOR_PLUGIN_DIR)) {
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
- console.log(` ${green(bold("Clean complete."))}`);
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
- } from "../chunk-YHG74UFF.js";
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-XOUYM27W.js";
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 modeState = await fetchModeState(apiKey, input.session_id);
174
- if (modeState.kill) return codexDeny(KILL_REASON);
175
- const lookup = toPolicyLookup(input.tool_name ?? "", input.tool_input ?? {}, input.cwd);
176
- const policy = await getPolicy(apiKey, modeState.policyVersion, "codex");
177
- const toolPolicy = resolvePolicy(policy, lookup.tool, modeState.mode, lookup.input);
178
- if (toolPolicy.timeoutSeconds === 0 && toolPolicy.timeoutAction === "approve") return codexAllow();
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 modeState = await fetchModeState(apiKey, input.session_id);
233
- if (modeState.kill) return codexDeny(KILL_REASON);
234
- const lookup = toPolicyLookup(input.tool_name ?? "", input.tool_input ?? {}, input.cwd);
235
- const policy = await getPolicy(apiKey, modeState.policyVersion, "codex");
236
- const toolPolicy = resolvePolicy(policy, lookup.tool, modeState.mode, lookup.input);
237
- if (toolPolicy.timeoutSeconds === 0 && toolPolicy.timeoutAction === "approve") return codexPass();
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;
@@ -3,7 +3,7 @@ import {
3
3
  askUser,
4
4
  reportEvent,
5
5
  waitForAnswer
6
- } from "../chunk-XOUYM27W.js";
6
+ } from "../chunk-H46LSQRT.js";
7
7
  import "../chunk-BSZYIAZL.js";
8
8
  import "../chunk-SAF6HGAA.js";
9
9
  import "../chunk-7QLSKOSU.js";
@@ -6,7 +6,7 @@ import {
6
6
  confirmAppConnection,
7
7
  connectDevice,
8
8
  printConnectInstructions
9
- } from "../chunk-V6OA4VPU.js";
9
+ } from "../chunk-P7VBAYQO.js";
10
10
  import "../chunk-3EGEA4KH.js";
11
11
  import {
12
12
  readKeySource