troxy-cli 1.29.23 → 1.29.24
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 +7 -0
- package/package.json +1 -1
- package/src/init.js +63 -20
- package/src/interceptor.js +29 -1
- package/src/mcp-server.js +27 -0
- package/src/sandbox/compress.js +52 -0
- package/src/sandbox/posttooluse-hook.js +119 -0
- package/src/sandbox/store.js +90 -0
- package/src/tests/claude-code-hook.test.js +65 -67
- package/src/tests/e2e-sandbox-chain.test.js +198 -0
- package/src/tests/init-sandbox-hook.test.js +173 -0
- package/src/tests/interceptor.test.js +59 -0
- package/src/tests/mcp-recall.test.js +22 -0
- package/src/tests/model-checkpoint.test.js +6 -5
- package/src/tests/posttooluse-hook.test.js +199 -0
- package/src/tests/proxy-wiring.test.js +6 -4
- package/src/tests/sandbox-compress.test.js +85 -0
- package/src/tests/sandbox-store.test.js +86 -0
- package/src/uninstall.js +2 -2
package/bin/troxy.js
CHANGED
|
@@ -277,6 +277,13 @@ switch (command) {
|
|
|
277
277
|
break;
|
|
278
278
|
}
|
|
279
279
|
|
|
280
|
+
// posttooluse-hook.js for tool-output sandboxing.
|
|
281
|
+
case 'posttooluse-hook': {
|
|
282
|
+
const { runPostToolUseHook } = await import('../src/sandbox/posttooluse-hook.js');
|
|
283
|
+
await runPostToolUseHook();
|
|
284
|
+
break;
|
|
285
|
+
}
|
|
286
|
+
|
|
280
287
|
// ── Heartbeat daemon (background service) ─────────────────────
|
|
281
288
|
case 'daemon': {
|
|
282
289
|
const { runDaemon } = await import('../src/daemon.js');
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "troxy-cli",
|
|
3
|
-
"version": "1.29.
|
|
3
|
+
"version": "1.29.24",
|
|
4
4
|
"description": "A secure control layer for AI agents: policies across payments, messages, logins, destructive actions, model usage, and secrets, all enforceable from the CLI",
|
|
5
5
|
"homepage": "https://troxy.io",
|
|
6
6
|
"bugs": {
|
package/src/init.js
CHANGED
|
@@ -142,7 +142,7 @@ export async function reprovisionKeyConsumers(key, agentName, proxyOptIn = null,
|
|
|
142
142
|
}
|
|
143
143
|
if (hasClaude || hasDesktop) {
|
|
144
144
|
try {
|
|
145
|
-
|
|
145
|
+
_patchClaudeCodeHooksFile(claudeCodeSettingsPath());
|
|
146
146
|
console.log(` • Claude Code usage capture (all projects) ✓`);
|
|
147
147
|
} catch (err) {
|
|
148
148
|
console.log(` • Claude Code usage capture ✗ (${err.message})`);
|
|
@@ -174,16 +174,24 @@ export async function reprovisionKeyConsumers(key, agentName, proxyOptIn = null,
|
|
|
174
174
|
|
|
175
175
|
console.log('\n Setting up background service...');
|
|
176
176
|
const viaEphemeralNpx = ranViaEphemeralNpx();
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
console.log(
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
console.log('
|
|
177
|
+
// Test-only escape hatch: lets tests exercise reprovisionKeyConsumers's
|
|
178
|
+
// hook-patching logic end to end without installService ever running -
|
|
179
|
+
// i.e. without touching the real launchd/systemd service on the host
|
|
180
|
+
// machine. Never set in production; see init-sandbox-hook.test.js.
|
|
181
|
+
if (process.env.TROXY_TEST_SKIP_SERVICE_INSTALL === '1') {
|
|
182
|
+
console.log(' Background service ✗ (skipped: TROXY_TEST_SKIP_SERVICE_INSTALL=1)');
|
|
183
|
+
} else {
|
|
184
|
+
try {
|
|
185
|
+
installService(key, agentName);
|
|
186
|
+
console.log(' Background service installed ✓');
|
|
187
|
+
if (viaEphemeralNpx) printPersistentInstallHint();
|
|
188
|
+
} catch (err) {
|
|
189
|
+
console.log(` Background service ✗ (${err.message})`);
|
|
190
|
+
if (viaEphemeralNpx) {
|
|
191
|
+
printPersistentInstallHint();
|
|
192
|
+
} else {
|
|
193
|
+
console.log(' You can start it manually with: troxy daemon &');
|
|
194
|
+
}
|
|
187
195
|
}
|
|
188
196
|
}
|
|
189
197
|
|
|
@@ -793,6 +801,10 @@ export function troxyPreToolUseCommand() {
|
|
|
793
801
|
return `${_resolveTroxyBin()} pretooluse-hook`;
|
|
794
802
|
}
|
|
795
803
|
|
|
804
|
+
export function troxyPostToolUseCommand() {
|
|
805
|
+
return `${_resolveTroxyBin()} posttooluse-hook`;
|
|
806
|
+
}
|
|
807
|
+
|
|
796
808
|
// Shared by patchClaudeCodeHooks and unpatchClaudeCodeHooks, so "what counts
|
|
797
809
|
// as a Troxy-owned hook entry" has exactly one definition.
|
|
798
810
|
function _isTroxyStopEntry(matcherEntry) {
|
|
@@ -803,14 +815,18 @@ function _isTroxyPreToolUseEntry(matcherEntry) {
|
|
|
803
815
|
return Array.isArray(matcherEntry?.hooks) &&
|
|
804
816
|
matcherEntry.hooks.some(h => typeof h?.command === 'string' && h.command.includes('pretooluse-hook'));
|
|
805
817
|
}
|
|
818
|
+
export function _isTroxyPostToolUseEntry(matcherEntry) {
|
|
819
|
+
return Array.isArray(matcherEntry?.hooks) &&
|
|
820
|
+
matcherEntry.hooks.some(h => typeof h?.command === 'string' && h.command.includes('posttooluse-hook'));
|
|
821
|
+
}
|
|
806
822
|
|
|
807
823
|
export function patchClaudeCodeHooks(
|
|
808
|
-
|
|
824
|
+
config,
|
|
825
|
+
options = {},
|
|
826
|
+
// legacy parameters for backward compat (unused; stopCommand and preToolUseCommand defaulted previously)
|
|
809
827
|
stopCommand = troxyHookCommand(),
|
|
810
828
|
preToolUseCommand = troxyPreToolUseCommand(),
|
|
811
829
|
) {
|
|
812
|
-
let config = {};
|
|
813
|
-
try { config = JSON.parse(fs.readFileSync(configPath, 'utf8')); } catch {}
|
|
814
830
|
if (!config.hooks) config.hooks = {};
|
|
815
831
|
|
|
816
832
|
if (!Array.isArray(config.hooks.Stop)) config.hooks.Stop = [];
|
|
@@ -832,22 +848,49 @@ export function patchClaudeCodeHooks(
|
|
|
832
848
|
if (preIdx >= 0) config.hooks.PreToolUse[preIdx] = preToolUseEntry;
|
|
833
849
|
else config.hooks.PreToolUse.push(preToolUseEntry);
|
|
834
850
|
|
|
835
|
-
|
|
836
|
-
|
|
851
|
+
// PostToolUse hook for tool-output sandboxing - always installed, unconditional
|
|
852
|
+
// (no opt-out: the `options` param is kept only for legacy call-signature compat).
|
|
853
|
+
if (!Array.isArray(config.hooks.PostToolUse)) config.hooks.PostToolUse = [];
|
|
854
|
+
const postToolUseCommand = troxyPostToolUseCommand();
|
|
855
|
+
const postToolUseEntry = { matcher: '*', hooks: [{ type: 'command', command: postToolUseCommand, timeout: 5 }] };
|
|
856
|
+
const postIdx = config.hooks.PostToolUse.findIndex(_isTroxyPostToolUseEntry);
|
|
857
|
+
if (postIdx >= 0) config.hooks.PostToolUse[postIdx] = postToolUseEntry;
|
|
858
|
+
else config.hooks.PostToolUse.push(postToolUseEntry);
|
|
859
|
+
|
|
860
|
+
return config;
|
|
837
861
|
}
|
|
838
862
|
|
|
839
863
|
// Uninstall symmetry (fail-open layer 6 in the plan doc): removes exactly
|
|
840
|
-
// the Troxy-owned Stop/PreToolUse entries, leaving any of the user's own
|
|
864
|
+
// the Troxy-owned Stop/PreToolUse/PostToolUse entries, leaving any of the user's own
|
|
841
865
|
// unrelated hooks in the same file untouched.
|
|
842
|
-
export function unpatchClaudeCodeHooks(
|
|
843
|
-
let config = {};
|
|
844
|
-
try { config = JSON.parse(fs.readFileSync(configPath, 'utf8')); } catch { return; }
|
|
866
|
+
export function unpatchClaudeCodeHooks(config) {
|
|
845
867
|
if (Array.isArray(config.hooks?.Stop)) {
|
|
846
868
|
config.hooks.Stop = config.hooks.Stop.filter(e => !_isTroxyStopEntry(e));
|
|
847
869
|
}
|
|
848
870
|
if (Array.isArray(config.hooks?.PreToolUse)) {
|
|
849
871
|
config.hooks.PreToolUse = config.hooks.PreToolUse.filter(e => !_isTroxyPreToolUseEntry(e));
|
|
850
872
|
}
|
|
873
|
+
if (Array.isArray(config.hooks?.PostToolUse)) {
|
|
874
|
+
config.hooks.PostToolUse = config.hooks.PostToolUse.filter(e => !_isTroxyPostToolUseEntry(e));
|
|
875
|
+
}
|
|
876
|
+
return config;
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
// File-based wrappers (for call sites that operate directly on file paths,
|
|
880
|
+
// e.g. uninstall.js's symmetrical teardown). The exported pure functions
|
|
881
|
+
// above (operating on an in-memory config object) are what's unit-tested.
|
|
882
|
+
export function _patchClaudeCodeHooksFile(configPath, options = {}) {
|
|
883
|
+
let config = {};
|
|
884
|
+
try { config = JSON.parse(fs.readFileSync(configPath, 'utf8')); } catch {}
|
|
885
|
+
config = patchClaudeCodeHooks(config, options);
|
|
886
|
+
fs.mkdirSync(path.dirname(configPath), { recursive: true });
|
|
887
|
+
fs.writeFileSync(configPath, JSON.stringify(config, null, 2) + '\n');
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
export function _unpatchClaudeCodeHooksFile(configPath) {
|
|
891
|
+
let config = {};
|
|
892
|
+
try { config = JSON.parse(fs.readFileSync(configPath, 'utf8')); } catch { return; }
|
|
893
|
+
config = unpatchClaudeCodeHooks(config);
|
|
851
894
|
fs.writeFileSync(configPath, JSON.stringify(config, null, 2) + '\n');
|
|
852
895
|
}
|
|
853
896
|
|
package/src/interceptor.js
CHANGED
|
@@ -30,6 +30,9 @@ import net from 'node:net';
|
|
|
30
30
|
import tls from 'node:tls';
|
|
31
31
|
import http from 'node:http';
|
|
32
32
|
import https from 'node:https';
|
|
33
|
+
import fs from 'node:fs';
|
|
34
|
+
import path from 'node:path';
|
|
35
|
+
import os from 'node:os';
|
|
33
36
|
|
|
34
37
|
// Found live 2026-09-08, first real terminal validation of this whole
|
|
35
38
|
// mechanism: a real `claude` process opens a burst of ~15 concurrent
|
|
@@ -93,7 +96,30 @@ export function detectSurface(incomingHeaders) {
|
|
|
93
96
|
* rides the side channel app.py already knows how to consume
|
|
94
97
|
* (X-Troxy-Upstream-Authorization), while Authorization itself becomes the
|
|
95
98
|
* Troxy key, matching how app.py authenticates every other proxied call. */
|
|
96
|
-
|
|
99
|
+
// Freshness window for the sandbox holdout/treatment state file written by
|
|
100
|
+
// the Task 3 hook after each tool call - a stale file (daemon restarted,
|
|
101
|
+
// hook not firing, clock skew) must never be reported as live state, so the
|
|
102
|
+
// header is omitted entirely rather than forwarding a stale value.
|
|
103
|
+
const _SANDBOX_STATE_FRESHNESS_MS = 2 * 60 * 1000;
|
|
104
|
+
const _DEFAULT_SANDBOX_STATE_DIR = path.join(os.homedir(), '.troxy', 'sandbox');
|
|
105
|
+
|
|
106
|
+
// Returns the hook's last recorded state ('treated' | 'holdout' | 'skipped')
|
|
107
|
+
// if the state file exists, parses, and was written within the freshness
|
|
108
|
+
// window - null otherwise (missing file, stale, unreadable, malformed).
|
|
109
|
+
// Never throws: this is a best-effort read on the hot request path.
|
|
110
|
+
function _readFreshSandboxState(sandboxStateDir) {
|
|
111
|
+
try {
|
|
112
|
+
const filePath = path.join(sandboxStateDir, 'last-state.json');
|
|
113
|
+
const stat = fs.statSync(filePath);
|
|
114
|
+
if (Date.now() - stat.mtimeMs > _SANDBOX_STATE_FRESHNESS_MS) return null;
|
|
115
|
+
const parsed = JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
|
116
|
+
return parsed.state || null;
|
|
117
|
+
} catch {
|
|
118
|
+
return null;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export function buildTroxyForwardHeaders(incomingHeaders, troxyKey, sandboxStateDir = _DEFAULT_SANDBOX_STATE_DIR) {
|
|
97
123
|
const headers = {};
|
|
98
124
|
for (const [k, v] of Object.entries(incomingHeaders)) {
|
|
99
125
|
if (_HOP_BY_HOP_HEADERS.has(k) || k === 'authorization' || k === 'accept-encoding') continue;
|
|
@@ -104,6 +130,8 @@ export function buildTroxyForwardHeaders(incomingHeaders, troxyKey) {
|
|
|
104
130
|
}
|
|
105
131
|
const surface = detectSurface(incomingHeaders);
|
|
106
132
|
if (surface) headers['x-troxy-surface'] = surface;
|
|
133
|
+
const sandboxState = _readFreshSandboxState(sandboxStateDir);
|
|
134
|
+
if (sandboxState) headers['x-troxy-tool-sandbox'] = sandboxState;
|
|
107
135
|
headers.authorization = `Bearer ${troxyKey}`;
|
|
108
136
|
return headers;
|
|
109
137
|
}
|
package/src/mcp-server.js
CHANGED
|
@@ -6,6 +6,7 @@ import {
|
|
|
6
6
|
} from '@modelcontextprotocol/sdk/types.js';
|
|
7
7
|
import { loadConfig } from './config.js';
|
|
8
8
|
import { evaluatePayment, api } from './api.js';
|
|
9
|
+
import { get as sandboxGet, storeDir } from './sandbox/store.js';
|
|
9
10
|
const confirmPayment = (auditId, body, apiKey) => api.confirmPayment(auditId, body, apiKey);
|
|
10
11
|
const evaluateEmail = (body, apiKey) => api.evaluateEmail(body, apiKey);
|
|
11
12
|
const evaluateLogin = (body, apiKey) => api.evaluateLogin(body, apiKey);
|
|
@@ -20,6 +21,14 @@ const reportModelUsage = (body, apiKey) => api.reportModelUsage(body, apiKey);
|
|
|
20
21
|
// agent whose only way to reach Troxy is through the tools listed here.
|
|
21
22
|
const evaluateSecrets = (body, apiKey) => api.evaluateSecrets(body, apiKey);
|
|
22
23
|
|
|
24
|
+
export async function handleRecall(args, dir = storeDir()) {
|
|
25
|
+
const content = await sandboxGet(args.handle, dir);
|
|
26
|
+
if (content === null) {
|
|
27
|
+
return { content: [{ type: 'text', text: `Troxy error: handle "${args.handle}" not found (it may have expired).` }], isError: true };
|
|
28
|
+
}
|
|
29
|
+
return { content: [{ type: 'text', text: content }], isError: false };
|
|
30
|
+
}
|
|
31
|
+
|
|
23
32
|
export async function runMcp() {
|
|
24
33
|
const config = loadConfig();
|
|
25
34
|
const apiKey = process.env.TROXY_API_KEY || config?.apiKey;
|
|
@@ -279,6 +288,20 @@ export async function runMcp() {
|
|
|
279
288
|
},
|
|
280
289
|
},
|
|
281
290
|
},
|
|
291
|
+
{
|
|
292
|
+
name: 'troxy_recall',
|
|
293
|
+
description:
|
|
294
|
+
'Fetch the full content behind a troxy_recall(...) handle shown after a large tool ' +
|
|
295
|
+
'output was compressed. Use this when the summary you were shown doesn\'t have the ' +
|
|
296
|
+
'detail you need.',
|
|
297
|
+
inputSchema: {
|
|
298
|
+
type: 'object',
|
|
299
|
+
required: ['handle'],
|
|
300
|
+
properties: {
|
|
301
|
+
handle: { type: 'string', description: 'The handle shown in the compressed output, e.g. "a1b2c3d4e5f6a7b8"' },
|
|
302
|
+
},
|
|
303
|
+
},
|
|
304
|
+
},
|
|
282
305
|
{
|
|
283
306
|
name: 'evaluate_login',
|
|
284
307
|
description:
|
|
@@ -565,6 +588,10 @@ export async function runMcp() {
|
|
|
565
588
|
return { content: [{ type: 'text', text: actionText }], isError: decision === 'BLOCK' };
|
|
566
589
|
}
|
|
567
590
|
|
|
591
|
+
if (toolName === 'troxy_recall') {
|
|
592
|
+
return handleRecall(args);
|
|
593
|
+
}
|
|
594
|
+
|
|
568
595
|
if (toolName === 'evaluate_model') {
|
|
569
596
|
if (agentName && !args.agent) args.agent = agentName;
|
|
570
597
|
let result;
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
const SANDBOX_THRESHOLD = 2000;
|
|
2
|
+
const MAX_SIGNAL_LINES = 20;
|
|
3
|
+
const CHAR_EDGE = 250;
|
|
4
|
+
|
|
5
|
+
// Covers: pytest failures (FAILED), jest failures (FAIL file summary + ✕ marker),
|
|
6
|
+
// go test failures (--- FAIL:), git/docker fatal errors, generic + fused
|
|
7
|
+
// exception names (ValueError:, NullPointerException - no word boundary
|
|
8
|
+
// required before the keyword since real exceptions fuse it to a prefix),
|
|
9
|
+
// python traceback headers and frame lines, and go/rust panics.
|
|
10
|
+
const SIGNAL_PATTERNS = [
|
|
11
|
+
/\bFAILED\b/,
|
|
12
|
+
/^FAIL\b/,
|
|
13
|
+
/✕/,
|
|
14
|
+
/--- FAIL:/,
|
|
15
|
+
/Error:/,
|
|
16
|
+
/Exception\b/i,
|
|
17
|
+
/\bfatal:/i,
|
|
18
|
+
/Traceback \(most recent call last\)/,
|
|
19
|
+
/File ".*", line \d+/,
|
|
20
|
+
/panic:/,
|
|
21
|
+
/panicked at/,
|
|
22
|
+
];
|
|
23
|
+
|
|
24
|
+
export function shouldSandbox(output) {
|
|
25
|
+
return typeof output === 'string' && output.length > SANDBOX_THRESHOLD;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function summarize(output) {
|
|
29
|
+
const lines = output.split('\n');
|
|
30
|
+
const signalLines = lines.filter((line) => SIGNAL_PATTERNS.some((p) => p.test(line)));
|
|
31
|
+
|
|
32
|
+
if (signalLines.length > 0) {
|
|
33
|
+
const kept = signalLines.slice(0, MAX_SIGNAL_LINES).join('\n');
|
|
34
|
+
const truncationNote = signalLines.length > MAX_SIGNAL_LINES
|
|
35
|
+
? ` (showing ${MAX_SIGNAL_LINES} of ${signalLines.length} matching lines)`
|
|
36
|
+
: '';
|
|
37
|
+
return `${kept}\n\n(${lines.length} lines, ${output.length} chars total - use troxy_recall to see the rest)${truncationNote}`;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Line-based head+tail slicing duplicates content when there are few
|
|
41
|
+
// enough lines for the two slices to overlap (e.g. one giant minified-JSON
|
|
42
|
+
// line) - fall back to character-based truncation of the raw string.
|
|
43
|
+
if (lines.length <= 6) {
|
|
44
|
+
const head = output.slice(0, CHAR_EDGE);
|
|
45
|
+
const tail = output.slice(-CHAR_EDGE);
|
|
46
|
+
return `${head}\n...\n${tail}\n\n(${output.length} chars omitted - use troxy_recall to see all of it)`;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const head = lines.slice(0, 3).join('\n');
|
|
50
|
+
const tail = lines.slice(-3).join('\n');
|
|
51
|
+
return `${head}\n...\n${tail}\n\n(${lines.length} lines, ${output.length} chars omitted - use troxy_recall to see all of it)`;
|
|
52
|
+
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { shouldSandbox, summarize } from './compress.js';
|
|
5
|
+
import { put, storeDir as defaultStoreDir } from './store.js';
|
|
6
|
+
|
|
7
|
+
const STDIN_TIMEOUT_MS = 2000;
|
|
8
|
+
|
|
9
|
+
function writeState(dir, state) {
|
|
10
|
+
try {
|
|
11
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
12
|
+
fs.writeFileSync(path.join(dir, 'last-state.json'), JSON.stringify({ state }));
|
|
13
|
+
} catch {
|
|
14
|
+
// Fails open - a state-file write failure must never surface as a hook error.
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// Real tool_response shapes, confirmed against this machine's own
|
|
19
|
+
// ~/.claude/projects/*/*.jsonl transcripts (not the invented `.output`
|
|
20
|
+
// field this file originally assumed - that field does not exist on any
|
|
21
|
+
// real tool result; see the 2026-10-05 whole-branch review for the
|
|
22
|
+
// incident this fixes). Bash/Bash-like tools dominate real traffic and
|
|
23
|
+
// carry the text in `stdout`; Read nests it under `file.content`; Write/
|
|
24
|
+
// Edit-created-file results carry it directly on `content`. Anything else
|
|
25
|
+
// (Grep's `matches` list, bare strings from MCP tools, structured Edit
|
|
26
|
+
// diffs, etc.) is left untouched rather than guessed at, since a wrong
|
|
27
|
+
// guess here means Claude Code rejects updatedToolOutput against the
|
|
28
|
+
// tool's own schema and reverts to the original output anyway - a correct
|
|
29
|
+
// no-op is strictly better than an incorrect one that also surfaces a
|
|
30
|
+
// visible hook error.
|
|
31
|
+
function extractText(toolResponse) {
|
|
32
|
+
if (typeof toolResponse === 'string') return toolResponse;
|
|
33
|
+
if (toolResponse && typeof toolResponse === 'object') {
|
|
34
|
+
if (typeof toolResponse.stdout === 'string') return toolResponse.stdout;
|
|
35
|
+
if (toolResponse.file && typeof toolResponse.file.content === 'string') return toolResponse.file.content;
|
|
36
|
+
if (typeof toolResponse.content === 'string') return toolResponse.content;
|
|
37
|
+
}
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Mirrors extractText's shape-matching exactly, replacing only the text
|
|
42
|
+
// field so the rest of the real object (stderr, filePath, structuredPatch,
|
|
43
|
+
// etc.) reaches Claude unchanged and passes the tool's own output schema.
|
|
44
|
+
function rebuildWithText(toolResponse, text) {
|
|
45
|
+
if (typeof toolResponse === 'string') return text;
|
|
46
|
+
if (toolResponse.file && typeof toolResponse.file.content === 'string') {
|
|
47
|
+
return { ...toolResponse, file: { ...toolResponse.file, content: text } };
|
|
48
|
+
}
|
|
49
|
+
if (typeof toolResponse.stdout === 'string') {
|
|
50
|
+
return { ...toolResponse, stdout: text };
|
|
51
|
+
}
|
|
52
|
+
return { ...toolResponse, content: text };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export async function buildHookResponse(toolUseBlock, {
|
|
56
|
+
sandboxFraction = 1,
|
|
57
|
+
holdoutFraction = 0.2,
|
|
58
|
+
randomFn = Math.random,
|
|
59
|
+
storeDir = defaultStoreDir(),
|
|
60
|
+
} = {}) {
|
|
61
|
+
const toolResponse = toolUseBlock?.tool_response;
|
|
62
|
+
const output = extractText(toolResponse);
|
|
63
|
+
if (output === null || !shouldSandbox(output)) {
|
|
64
|
+
writeState(storeDir, 'skipped');
|
|
65
|
+
return undefined;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (randomFn() < sandboxFraction && randomFn() < holdoutFraction) {
|
|
69
|
+
writeState(storeDir, 'holdout');
|
|
70
|
+
return undefined;
|
|
71
|
+
}
|
|
72
|
+
if (randomFn() >= sandboxFraction) {
|
|
73
|
+
writeState(storeDir, 'skipped');
|
|
74
|
+
return undefined;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
try {
|
|
78
|
+
const summary = summarize(output);
|
|
79
|
+
const { handle } = await put(output, { summary, tags: [] }, storeDir);
|
|
80
|
+
writeState(storeDir, 'treated');
|
|
81
|
+
const updatedText = `${summary}\n\n(troxy_recall("${handle}") to fetch the full output)`;
|
|
82
|
+
return {
|
|
83
|
+
hookSpecificOutput: {
|
|
84
|
+
hookEventName: 'PostToolUse',
|
|
85
|
+
updatedToolOutput: rebuildWithText(toolResponse, updatedText),
|
|
86
|
+
},
|
|
87
|
+
};
|
|
88
|
+
} catch {
|
|
89
|
+
// Fail open: storage broke, show the real output rather than losing it.
|
|
90
|
+
writeState(storeDir, 'skipped');
|
|
91
|
+
return undefined;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
async function readStdin(timeoutMs) {
|
|
96
|
+
return new Promise((resolve) => {
|
|
97
|
+
let data = '';
|
|
98
|
+
const timer = setTimeout(() => resolve(data), timeoutMs);
|
|
99
|
+
process.stdin.on('data', (chunk) => { data += chunk; });
|
|
100
|
+
process.stdin.on('end', () => { clearTimeout(timer); resolve(data); });
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Exported entry point - invoked both by bin/troxy.js's dynamic import
|
|
105
|
+
// (`troxy posttooluse-hook`, registered via patchClaudeCodeHooks in
|
|
106
|
+
// init.js) and, below, by this file's own direct-execution guard.
|
|
107
|
+
export async function runPostToolUseHook() {
|
|
108
|
+
try {
|
|
109
|
+
const raw = await readStdin(STDIN_TIMEOUT_MS);
|
|
110
|
+
const input = JSON.parse(raw);
|
|
111
|
+
const response = await buildHookResponse(input);
|
|
112
|
+
if (response) process.stdout.write(JSON.stringify(response));
|
|
113
|
+
} catch {
|
|
114
|
+
// Fail open on any parse/unexpected error - never block the real tool output.
|
|
115
|
+
}
|
|
116
|
+
process.exit(0);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
if (import.meta.url === `file://${process.argv[1]}`) runPostToolUseHook();
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import crypto from 'node:crypto';
|
|
5
|
+
|
|
6
|
+
export function storeDir() {
|
|
7
|
+
return path.join(os.homedir(), '.troxy', 'sandbox');
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function indexPath(dir) {
|
|
11
|
+
return path.join(dir, 'index.json');
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
// A plain exclusive-create lockfile, retried with backoff - no new dependency,
|
|
15
|
+
// good enough for a handful of concurrent Claude Code sessions on one machine.
|
|
16
|
+
async function withIndexLock(dir, fn) {
|
|
17
|
+
const lockPath = path.join(dir, 'index.lock');
|
|
18
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
19
|
+
let acquired = false;
|
|
20
|
+
for (let attempt = 0; attempt < 50; attempt++) {
|
|
21
|
+
try {
|
|
22
|
+
fs.writeFileSync(lockPath, String(process.pid), { flag: 'wx' });
|
|
23
|
+
acquired = true;
|
|
24
|
+
break;
|
|
25
|
+
} catch (err) {
|
|
26
|
+
if (err.code !== 'EEXIST') throw err;
|
|
27
|
+
await new Promise((r) => setTimeout(r, 10 + Math.random() * 20));
|
|
28
|
+
if (attempt === 49) {
|
|
29
|
+
fs.rmSync(lockPath, { force: true }); // stale lock (original holder presumed dead) - steal it
|
|
30
|
+
try {
|
|
31
|
+
fs.writeFileSync(lockPath, String(process.pid), { flag: 'wx' });
|
|
32
|
+
acquired = true;
|
|
33
|
+
} catch (err2) {
|
|
34
|
+
// ponytail: another process won the just-stolen slot - rare double-steal race.
|
|
35
|
+
// Proceed unprotected rather than hang forever; upgrade to a PID-liveness check if this bites.
|
|
36
|
+
if (err2.code !== 'EEXIST') throw err2;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
try {
|
|
42
|
+
return fn();
|
|
43
|
+
} finally {
|
|
44
|
+
if (acquired) fs.rmSync(lockPath, { force: true });
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function readIndex(dir) {
|
|
49
|
+
try {
|
|
50
|
+
return JSON.parse(fs.readFileSync(indexPath(dir), 'utf8'));
|
|
51
|
+
} catch {
|
|
52
|
+
return [];
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export async function put(content, { summary, tags = [] }, dir = storeDir()) {
|
|
57
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
58
|
+
const handle = crypto.createHash('sha256').update(content).digest('hex').slice(0, 16);
|
|
59
|
+
fs.writeFileSync(path.join(dir, `${handle}.txt`), content, 'utf8');
|
|
60
|
+
await withIndexLock(dir, () => {
|
|
61
|
+
const index = readIndex(dir);
|
|
62
|
+
index.push({ handle, summary, tags, createdAt: new Date().toISOString(), size: content.length });
|
|
63
|
+
fs.writeFileSync(indexPath(dir), JSON.stringify(index));
|
|
64
|
+
});
|
|
65
|
+
return { handle, bytesSaved: Math.max(0, content.length - summary.length) };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// The handle is agent-controlled (the model supplies it as a troxy_recall
|
|
69
|
+
// argument), so it must be validated before touching the filesystem - a
|
|
70
|
+
// handle like "../../../etc/passwd" would otherwise let a prompt-injected
|
|
71
|
+
// tool call read any file the process can see. Real handles are always a
|
|
72
|
+
// 16-char lowercase hex digest (see put()); reject anything else outright.
|
|
73
|
+
const HANDLE_PATTERN = /^[0-9a-f]{16}$/;
|
|
74
|
+
|
|
75
|
+
export async function get(handle, dir = storeDir()) {
|
|
76
|
+
if (typeof handle !== 'string' || !HANDLE_PATTERN.test(handle)) return null;
|
|
77
|
+
try {
|
|
78
|
+
return fs.readFileSync(path.join(dir, `${handle}.txt`), 'utf8');
|
|
79
|
+
} catch {
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export async function search(query, dir = storeDir()) {
|
|
85
|
+
const needle = query.toLowerCase();
|
|
86
|
+
const index = readIndex(dir);
|
|
87
|
+
const hits = index.filter((e) =>
|
|
88
|
+
e.summary.toLowerCase().includes(needle) || e.tags.some((t) => t.toLowerCase().includes(needle)));
|
|
89
|
+
return hits.slice(-5).reverse().map(({ handle, summary }) => ({ handle, summary }));
|
|
90
|
+
}
|