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 +11 -1
- package/package.json +1 -1
- package/src/init.js +47 -17
- package/src/pretooluse-hook.js +113 -0
- package/src/tests/claude-code-hook.test.js +55 -0
- package/src/tests/pretooluse-hook.test.js +42 -0
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:
|
|
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
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:
|
|
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
|
|
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
|
-
|
|
493
|
-
let troxy;
|
|
490
|
+
function _resolveTroxyBin() {
|
|
494
491
|
try {
|
|
495
|
-
|
|
492
|
+
return execSync('which troxy').toString().trim();
|
|
496
493
|
} catch {
|
|
497
|
-
|
|
494
|
+
return 'npx troxy-cli';
|
|
498
495
|
}
|
|
499
|
-
return `${troxy} hook-report`;
|
|
500
496
|
}
|
|
501
497
|
|
|
502
|
-
export function
|
|
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
|
-
|
|
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
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
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
|
+
});
|