troxy-cli 1.20.0 → 1.21.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/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');
@@ -541,7 +551,7 @@ switch (command) {
541
551
  default:
542
552
  if (command) console.error(` Unknown command: ${command}\n`);
543
553
  console.log(`
544
- Troxy: AI payment control
554
+ Troxy: a secure control layer for AI agents
545
555
 
546
556
  This CLI has two parts:
547
557
  - Commands below (policies, mcps, activity, ...) are for YOU to run and manage your account.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "troxy-cli",
3
- "version": "1.20.0",
3
+ "version": "1.21.0",
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
@@ -246,7 +246,7 @@ export async function runInit({ key, name } = {}) {
246
246
  process.exit(1);
247
247
  }
248
248
 
249
- console.log('\n Troxy: AI payment control\n');
249
+ console.log('\n Troxy: a secure control layer for AI agents\n');
250
250
 
251
251
  // Validate the key by hitting /evaluate (404 = key is valid, TypeError = network failure)
252
252
  process.stdout.write(' Validating API key... ');
@@ -304,9 +304,7 @@ export async function runInit({ key, name } = {}) {
304
304
 
305
305
  console.log('\n Your payments are now protected.');
306
306
  console.log(' Dashboard → https://dash.troxy.io');
307
- console.log('\n Try it:');
308
- console.log(` troxy pay --merchant "Test" --amount 10`);
309
- console.log(' This will appear in your dashboard within seconds.\n');
307
+ console.log('\n For more information, visit https://docs.troxy.io\n');
310
308
  }
311
309
 
312
310
  function installService(apiKey, agentName) {
@@ -489,30 +487,62 @@ function claudeCodeSettingsPath() {
489
487
  return path.join(os.homedir(), '.claude', 'settings.json');
490
488
  }
491
489
 
492
- export function troxyHookCommand() {
493
- let troxy;
490
+ function _resolveTroxyBin() {
494
491
  try {
495
- troxy = execSync('which troxy').toString().trim();
492
+ return execSync('which troxy').toString().trim();
496
493
  } catch {
497
- troxy = 'npx troxy-cli';
494
+ return 'npx troxy-cli';
498
495
  }
499
- return `${troxy} hook-report`;
500
496
  }
501
497
 
502
- 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
+ ) {
503
518
  let config = {};
504
519
  try { config = JSON.parse(fs.readFileSync(configPath, 'utf8')); } catch {}
505
520
  if (!config.hooks) config.hooks = {};
506
- if (!Array.isArray(config.hooks.Stop)) config.hooks.Stop = [];
507
521
 
508
- const isTroxyEntry = (matcherEntry) =>
522
+ if (!Array.isArray(config.hooks.Stop)) config.hooks.Stop = [];
523
+ const isTroxyStopEntry = (matcherEntry) =>
509
524
  Array.isArray(matcherEntry?.hooks) &&
510
525
  matcherEntry.hooks.some(h => typeof h?.command === 'string' && h.command.includes('hook-report'));
511
-
512
- const troxyEntry = { hooks: [{ type: 'command', command }] };
513
- const idx = config.hooks.Stop.findIndex(isTroxyEntry);
514
- if (idx >= 0) config.hooks.Stop[idx] = troxyEntry;
515
- 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);
516
546
 
517
547
  fs.mkdirSync(path.dirname(configPath), { recursive: true });
518
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
+ });