troxy-cli 1.20.1 → 1.21.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/bin/troxy.js CHANGED
@@ -188,6 +188,16 @@ switch (command) {
188
188
  break;
189
189
  }
190
190
 
191
+ // ── Claude Code PreToolUse hook: gates model choice before a tool runs ─
192
+ // Invoked by Claude Code itself (registered via patchClaudeCodeHooks in
193
+ // init.js), never by a user directly. Must never throw or hang - see
194
+ // pretooluse-hook.js's own top-of-file note.
195
+ case 'pretooluse-hook': {
196
+ const { runPreToolUseHook } = await import('../src/pretooluse-hook.js');
197
+ await runPreToolUseHook();
198
+ break;
199
+ }
200
+
191
201
  // ── Heartbeat daemon (background service) ─────────────────────
192
202
  case 'daemon': {
193
203
  const { runDaemon } = await import('../src/daemon.js');
@@ -490,7 +500,7 @@ switch (command) {
490
500
  // is its picture of which checkpoints exist, and that lives on the server.
491
501
  // This is a no-op on a machine with an MCP client, which gets a fresh tool
492
502
  // list from the MCP handshake every session anyway.
493
- const { refreshHostedInstructions } = await import('../src/init.js');
503
+ const { refreshHostedInstructions, reprovisionKeyConsumers } = await import('../src/init.js');
494
504
  const refresh = async () => {
495
505
  try {
496
506
  return await refreshHostedInstructions((loadConfig() || {}).apiKey);
@@ -521,7 +531,26 @@ switch (command) {
521
531
  }
522
532
  // Also refresh the npx cache so MCP servers (e.g. OpenClaw) pick up the new version
523
533
  try { execSync(`npx --yes troxy-cli@${latest} --version`, { stdio: 'pipe' }); } catch { /* non-fatal */ }
524
- console.log(`✓\n\n Updated to ${latest}. Restart your terminal and any MCP clients to use the new version.`);
534
+ console.log(`✓\n\n Updated to ${latest}.`);
535
+
536
+ // Re-run the exact same client/hook provisioning `troxy init` does,
537
+ // using the key already saved on this machine - a version bump can
538
+ // change what gets registered (e.g. 1.21.0 added a PreToolUse hook
539
+ // alongside the existing Stop hook), and `update` silently leaving
540
+ // that unregistered meant the new version was installed but inert
541
+ // until someone thought to re-run `init` by hand. Best-effort: a
542
+ // failure here must not make `update` itself look like it failed when
543
+ // the actual package upgrade already succeeded.
544
+ const savedConfig = loadConfig();
545
+ if (savedConfig?.apiKey) {
546
+ try {
547
+ await reprovisionKeyConsumers(savedConfig.apiKey, savedConfig.agentName);
548
+ } catch (err) {
549
+ console.error(`\n Could not refresh MCP client config: ${err.message}`);
550
+ console.error(' Run "troxy init --key <your-key>" by hand to finish updating.\n');
551
+ }
552
+ }
553
+ console.log('\n Restart your terminal and any MCP clients to use the new version.');
525
554
  if (!await refresh()) console.log('');
526
555
  } catch (err) {
527
556
  const stderr = err.stderr?.toString() || err.message || '';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "troxy-cli",
3
- "version": "1.20.1",
3
+ "version": "1.21.1",
4
4
  "description": "Control layer for AI agents: check payments, emails, logins and destructive actions against your policies",
5
5
  "homepage": "https://troxy.io",
6
6
  "bugs": {
package/src/init.js CHANGED
@@ -487,30 +487,62 @@ function claudeCodeSettingsPath() {
487
487
  return path.join(os.homedir(), '.claude', 'settings.json');
488
488
  }
489
489
 
490
- export function troxyHookCommand() {
491
- let troxy;
490
+ function _resolveTroxyBin() {
492
491
  try {
493
- troxy = execSync('which troxy').toString().trim();
492
+ return execSync('which troxy').toString().trim();
494
493
  } catch {
495
- troxy = 'npx troxy-cli';
494
+ return 'npx troxy-cli';
496
495
  }
497
- return `${troxy} hook-report`;
498
496
  }
499
497
 
500
- export function patchClaudeCodeHooks(configPath, command = troxyHookCommand()) {
498
+ export function troxyHookCommand() {
499
+ return `${_resolveTroxyBin()} hook-report`;
500
+ }
501
+
502
+ // PreToolUse gate: fires before every tool call and can actually block one,
503
+ // unlike the Stop hook above (report-only, after the fact). Added because a
504
+ // model-domain policy was previously only enforceable if the connected
505
+ // agent voluntarily called the evaluate_model MCP tool first - proven live
506
+ // to not happen (a real Claude Code session used its own Bash tool directly
507
+ // without ever checking in, even on a fresh session with a valid key). See
508
+ // pretooluse-hook.js for the full reasoning and fail-open guarantees.
509
+ export function troxyPreToolUseCommand() {
510
+ return `${_resolveTroxyBin()} pretooluse-hook`;
511
+ }
512
+
513
+ export function patchClaudeCodeHooks(
514
+ configPath,
515
+ stopCommand = troxyHookCommand(),
516
+ preToolUseCommand = troxyPreToolUseCommand(),
517
+ ) {
501
518
  let config = {};
502
519
  try { config = JSON.parse(fs.readFileSync(configPath, 'utf8')); } catch {}
503
520
  if (!config.hooks) config.hooks = {};
504
- if (!Array.isArray(config.hooks.Stop)) config.hooks.Stop = [];
505
521
 
506
- const isTroxyEntry = (matcherEntry) =>
522
+ if (!Array.isArray(config.hooks.Stop)) config.hooks.Stop = [];
523
+ const isTroxyStopEntry = (matcherEntry) =>
507
524
  Array.isArray(matcherEntry?.hooks) &&
508
525
  matcherEntry.hooks.some(h => typeof h?.command === 'string' && h.command.includes('hook-report'));
509
-
510
- const troxyEntry = { hooks: [{ type: 'command', command }] };
511
- const idx = config.hooks.Stop.findIndex(isTroxyEntry);
512
- if (idx >= 0) config.hooks.Stop[idx] = troxyEntry;
513
- else config.hooks.Stop.push(troxyEntry);
526
+ const stopEntry = { hooks: [{ type: 'command', command: stopCommand }] };
527
+ const stopIdx = config.hooks.Stop.findIndex(isTroxyStopEntry);
528
+ if (stopIdx >= 0) config.hooks.Stop[stopIdx] = stopEntry;
529
+ else config.hooks.Stop.push(stopEntry);
530
+
531
+ // matcher: '*' means "every tool" in Claude Code's own hook config syntax
532
+ // - NOT the regex '.*', which is a different (and wrong) pattern here.
533
+ // A short "timeout" (seconds) on the hook entry itself is a second,
534
+ // independent fail-open guarantee on top of pretooluse-hook.js's own
535
+ // internal race: even if the Node process somehow hung despite that,
536
+ // Claude Code cancels the hook and lets the tool proceed once this
537
+ // elapses (its documented behavior for a timed-out PreToolUse hook).
538
+ if (!Array.isArray(config.hooks.PreToolUse)) config.hooks.PreToolUse = [];
539
+ const isTroxyPreToolUseEntry = (matcherEntry) =>
540
+ Array.isArray(matcherEntry?.hooks) &&
541
+ matcherEntry.hooks.some(h => typeof h?.command === 'string' && h.command.includes('pretooluse-hook'));
542
+ const preToolUseEntry = { matcher: '*', hooks: [{ type: 'command', command: preToolUseCommand, timeout: 5 }] };
543
+ const preIdx = config.hooks.PreToolUse.findIndex(isTroxyPreToolUseEntry);
544
+ if (preIdx >= 0) config.hooks.PreToolUse[preIdx] = preToolUseEntry;
545
+ else config.hooks.PreToolUse.push(preToolUseEntry);
514
546
 
515
547
  fs.mkdirSync(path.dirname(configPath), { recursive: true });
516
548
  fs.writeFileSync(configPath, JSON.stringify(config, null, 2) + '\n');
@@ -0,0 +1,113 @@
1
+ import { loadConfig } from './config.js';
2
+ import { api } from './api.js';
3
+ import { usageForCurrentTurn } from './hook-report.js';
4
+
5
+ // Claude Code PreToolUse hook entry point: `troxy pretooluse-hook` (wired in
6
+ // bin/troxy.js, registered by patchClaudeCodeHooks in init.js). Fires before
7
+ // EVERY tool call, in every project, for anyone who has run `troxy init` -
8
+ // unlike hook-report.js (which only ever reports, after the fact), this one
9
+ // can actually block. That makes fail-open correctness even more load-
10
+ // bearing here: a bug that hangs or wrongly denies would degrade every tool
11
+ // call in every project, not just Token Optimization data.
12
+ //
13
+ // Why this exists: a model-domain policy was previously only enforceable if
14
+ // the connected agent voluntarily called the evaluate_model MCP tool before
15
+ // acting on its own - proven live to not happen (a real Claude Code session
16
+ // just used its own Bash tool directly, never checked in, even on a fresh
17
+ // session with a valid key). PreToolUse is the host's own gate, not the
18
+ // agent's discretion, so it's the first mechanism that actually enforces a
19
+ // model policy instead of hoping the agent asks first.
20
+ //
21
+ // Everything in this file must fail open. A broken or slow check must never
22
+ // block a tool call that a real policy would have allowed anyway - see the
23
+ // GATE_TIMEOUT_MS race below, on top of the timeout Claude Code itself
24
+ // enforces on the hook process (settings.json's "timeout" field, set when
25
+ // this hook is registered).
26
+
27
+ const STDIN_TIMEOUT_MS = 1000;
28
+ const GATE_TIMEOUT_MS = 2500; // fail open fast - well under Claude Code's own hook timeout
29
+
30
+ function withTimeout(promise, ms) {
31
+ return Promise.race([
32
+ promise,
33
+ new Promise((_, reject) => setTimeout(() => reject(new Error('timeout')), ms)),
34
+ ]);
35
+ }
36
+
37
+ async function readStdin() {
38
+ const chunks = [];
39
+ for await (const chunk of process.stdin) chunks.push(chunk);
40
+ return Buffer.concat(chunks).toString('utf8');
41
+ }
42
+
43
+ // Pure - the exact contract Claude Code's docs specify for PreToolUse: exit
44
+ // 0 with this JSON on stdout is what blocks, not the legacy exit-code-2
45
+ // convention. A wrong field name here fails open (invalid JSON is a non-
46
+ // blocking error per Claude Code's own docs), never fails closed - so
47
+ // getting this exactly right is what makes the gate work at all, not just
48
+ // what makes it safe.
49
+ export function buildDenyPayload(reason) {
50
+ return {
51
+ hookSpecificOutput: {
52
+ hookEventName: 'PreToolUse',
53
+ permissionDecision: 'deny',
54
+ permissionDecisionReason: reason,
55
+ },
56
+ };
57
+ }
58
+
59
+ function allow() {
60
+ process.exit(0);
61
+ }
62
+
63
+ function deny(reason) {
64
+ process.stdout.write(JSON.stringify(buildDenyPayload(reason)));
65
+ process.exit(0); // exit 0, not 2 - the JSON permissionDecision is what blocks (modern contract)
66
+ }
67
+
68
+ export async function runPreToolUseHook() {
69
+ try {
70
+ const raw = await withTimeout(readStdin(), STDIN_TIMEOUT_MS);
71
+ let payload;
72
+ try {
73
+ payload = JSON.parse(raw);
74
+ } catch {
75
+ return allow(); // can't parse our own input - never block on that
76
+ }
77
+
78
+ const transcriptPath = payload?.transcript_path;
79
+ if (!transcriptPath) return allow();
80
+
81
+ // transcript_path can lag the current turn by a beat (Claude Code's own
82
+ // docs note this), so this may occasionally read the previous turn's
83
+ // model instead of one just switched to mid-conversation. Worst case:
84
+ // one tool call briefly evaluated against the wrong (but recent) model -
85
+ // not a wrong-direction failure (never wrongly denies because of it),
86
+ // just an occasional miss, consistent with fail-open throughout this file.
87
+ const usage = usageForCurrentTurn(transcriptPath);
88
+ if (!usage || !usage.model) return allow(); // nothing to gate against yet
89
+
90
+ const config = loadConfig();
91
+ const apiKey = process.env.TROXY_API_KEY || config?.apiKey;
92
+ if (!apiKey) return allow();
93
+
94
+ const result = await withTimeout(
95
+ api.evaluateModel({ model: usage.model, task: payload.tool_name || '' }, apiKey),
96
+ GATE_TIMEOUT_MS,
97
+ );
98
+
99
+ if (result?.decision === 'BLOCK') {
100
+ return deny(`Troxy: blocked by policy - ${result.reason || `the model "${usage.model}" is not allowed.`}`);
101
+ }
102
+ // ALLOW, ESCALATE, or anything unexpected: don't block. ESCALATE has no
103
+ // synchronous human-in-the-loop path from inside a hook (there is
104
+ // nowhere here to pause and wait on an approval), so it fails open
105
+ // rather than hanging Claude Code - same reasoning as every other
106
+ // "never block on our own uncertainty" branch in this file.
107
+ return allow();
108
+ } catch {
109
+ // Network error, timeout, malformed response - never block the user's
110
+ // session over our own failure.
111
+ return allow();
112
+ }
113
+ }
@@ -123,3 +123,58 @@ describe('patchClaudeCodeHooks', () => {
123
123
  assert.doesNotThrow(() => patchClaudeCodeHooks(configPath(), 'troxy hook-report'));
124
124
  });
125
125
  });
126
+
127
+ describe('patchClaudeCodeHooks: PreToolUse gate', () => {
128
+ const configPath = () => path.join(dir, 'settings.json');
129
+ const read = () => JSON.parse(fs.readFileSync(configPath(), 'utf8'));
130
+
131
+ it('creates the PreToolUse entry alongside Stop, matching every tool', () => {
132
+ patchClaudeCodeHooks(configPath(), 'troxy hook-report', '/usr/local/bin/troxy pretooluse-hook');
133
+ const cfg = read();
134
+ assert.equal(cfg.hooks.PreToolUse.length, 1);
135
+ assert.equal(cfg.hooks.PreToolUse[0].hooks[0].command, '/usr/local/bin/troxy pretooluse-hook');
136
+ // '*' (not the regex '.*') is Claude Code's own documented syntax for
137
+ // "every tool" - the wrong one here means the gate silently never fires.
138
+ assert.equal(cfg.hooks.PreToolUse[0].matcher, '*');
139
+ });
140
+
141
+ it('sets a short per-hook timeout as a second, independent fail-open guarantee', () => {
142
+ patchClaudeCodeHooks(configPath(), 'troxy hook-report', 'troxy pretooluse-hook');
143
+ const cfg = read();
144
+ assert.ok(cfg.hooks.PreToolUse[0].hooks[0].timeout > 0);
145
+ assert.ok(cfg.hooks.PreToolUse[0].hooks[0].timeout <= 10, 'timeout should be short, not Claude Code\'s 600s default');
146
+ });
147
+
148
+ it('is idempotent: running twice produces one Troxy PreToolUse entry, not two', () => {
149
+ patchClaudeCodeHooks(configPath(), 'troxy hook-report', 'troxy pretooluse-hook');
150
+ patchClaudeCodeHooks(configPath(), 'troxy hook-report', 'troxy pretooluse-hook');
151
+ const cfg = read();
152
+ assert.equal(cfg.hooks.PreToolUse.length, 1);
153
+ });
154
+
155
+ it('updates the PreToolUse command in place on a re-run rather than appending', () => {
156
+ patchClaudeCodeHooks(configPath(), 'troxy hook-report', 'npx troxy-cli pretooluse-hook');
157
+ patchClaudeCodeHooks(configPath(), 'troxy hook-report', '/usr/local/bin/troxy pretooluse-hook');
158
+ const cfg = read();
159
+ assert.equal(cfg.hooks.PreToolUse.length, 1);
160
+ assert.equal(cfg.hooks.PreToolUse[0].hooks[0].command, '/usr/local/bin/troxy pretooluse-hook');
161
+ });
162
+
163
+ it("preserves the user's own unrelated PreToolUse hooks in the same file", () => {
164
+ fs.writeFileSync(configPath(), JSON.stringify({
165
+ hooks: { PreToolUse: [{ matcher: 'Write', hooks: [{ type: 'command', command: 'validate.sh' }] }] },
166
+ }));
167
+ patchClaudeCodeHooks(configPath(), 'troxy hook-report', 'troxy pretooluse-hook');
168
+ const cfg = read();
169
+ assert.equal(cfg.hooks.PreToolUse.length, 2);
170
+ assert.ok(cfg.hooks.PreToolUse.some(e => e.hooks[0].command === 'validate.sh'));
171
+ assert.ok(cfg.hooks.PreToolUse.some(e => e.hooks[0].command === 'troxy pretooluse-hook'));
172
+ });
173
+
174
+ it('registering PreToolUse never touches the Stop hook entry', () => {
175
+ patchClaudeCodeHooks(configPath(), 'troxy hook-report', 'troxy pretooluse-hook');
176
+ const cfg = read();
177
+ assert.equal(cfg.hooks.Stop.length, 1);
178
+ assert.equal(cfg.hooks.Stop[0].hooks[0].command, 'troxy hook-report');
179
+ });
180
+ });
@@ -0,0 +1,42 @@
1
+ // The PreToolUse hook is the first mechanism that actually GATES a model
2
+ // policy, rather than hoping the connected agent voluntarily calls the
3
+ // evaluate_model MCP tool first - proven live not to happen (a real Claude
4
+ // Code session used its own Bash tool directly without ever checking in,
5
+ // even with a valid key freshly connected). Unlike the Stop hook
6
+ // (hook-report.js, report-only), a bug here has real blast radius: it fires
7
+ // before every tool call, in every project, for anyone who has run
8
+ // `troxy init`. buildDenyPayload's exact shape is what Claude Code's docs
9
+ // specify for a PreToolUse hook to actually block - a wrong field name
10
+ // fails OPEN (invalid JSON is documented as a non-blocking error), so this
11
+ // is the one place in this file where getting the shape exactly right is
12
+ // what makes the gate work at all, not just what makes it safe.
13
+
14
+ import { describe, it } from 'node:test';
15
+ import assert from 'node:assert/strict';
16
+
17
+ import { buildDenyPayload } from '../pretooluse-hook.js';
18
+
19
+ describe('buildDenyPayload', () => {
20
+ it('matches the exact PreToolUse hookSpecificOutput contract', () => {
21
+ const payload = buildDenyPayload('some reason');
22
+ assert.deepEqual(payload, {
23
+ hookSpecificOutput: {
24
+ hookEventName: 'PreToolUse',
25
+ permissionDecision: 'deny',
26
+ permissionDecisionReason: 'some reason',
27
+ },
28
+ });
29
+ });
30
+
31
+ it('is valid JSON when stringified (a parse failure here fails open, not closed)', () => {
32
+ assert.doesNotThrow(() => JSON.stringify(buildDenyPayload('reason')));
33
+ });
34
+
35
+ it('carries the reason through unmodified', () => {
36
+ const payload = buildDenyPayload('Troxy: blocked by policy - the model "claude-opus-4" is not allowed.');
37
+ assert.equal(
38
+ payload.hookSpecificOutput.permissionDecisionReason,
39
+ 'Troxy: blocked by policy - the model "claude-opus-4" is not allowed.',
40
+ );
41
+ });
42
+ });