troxy-cli 1.29.23 → 1.29.25

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
@@ -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.23",
3
+ "version": "1.29.25",
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
- patchClaudeCodeHooks(claudeCodeSettingsPath());
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
- try {
178
- installService(key, agentName);
179
- console.log(' Background service installed ✓');
180
- if (viaEphemeralNpx) printPersistentInstallHint();
181
- } catch (err) {
182
- console.log(` Background service ✗ (${err.message})`);
183
- if (viaEphemeralNpx) {
184
- printPersistentInstallHint();
185
- } else {
186
- console.log(' You can start it manually with: troxy daemon &');
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
- configPath,
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
- fs.mkdirSync(path.dirname(configPath), { recursive: true });
836
- fs.writeFileSync(configPath, JSON.stringify(config, null, 2) + '\n');
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(configPath) {
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
 
@@ -30,6 +30,10 @@ 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';
36
+ import { sessionArmPath } from './sandbox/posttooluse-hook.js';
33
37
 
34
38
  // Found live 2026-09-08, first real terminal validation of this whole
35
39
  // mechanism: a real `claude` process opens a burst of ~15 concurrent
@@ -93,7 +97,45 @@ export function detectSurface(incomingHeaders) {
93
97
  * rides the side channel app.py already knows how to consume
94
98
  * (X-Troxy-Upstream-Authorization), while Authorization itself becomes the
95
99
  * Troxy key, matching how app.py authenticates every other proxied call. */
96
- export function buildTroxyForwardHeaders(incomingHeaders, troxyKey) {
100
+ // Freshness window for the sandbox holdout/treatment state file written by
101
+ // the Task 3 hook after each tool call - a stale file (daemon restarted,
102
+ // hook not firing, clock skew) must never be reported as live state, so the
103
+ // header is omitted entirely rather than forwarding a stale value.
104
+ const _SANDBOX_STATE_FRESHNESS_MS = 2 * 60 * 1000;
105
+ const _DEFAULT_SANDBOX_STATE_DIR = path.join(os.homedir(), '.troxy', 'sandbox');
106
+
107
+ // Returns the hook's last recorded state ('treated' | 'holdout' | 'skipped')
108
+ // if the state file exists, parses, and was written within the freshness
109
+ // window - null otherwise (missing file, stale, unreadable, malformed).
110
+ // Never throws: this is a best-effort read on the hot request path.
111
+ function _readFreshSandboxState(sandboxStateDir) {
112
+ try {
113
+ const filePath = path.join(sandboxStateDir, 'last-state.json');
114
+ const stat = fs.statSync(filePath);
115
+ if (Date.now() - stat.mtimeMs > _SANDBOX_STATE_FRESHNESS_MS) return null;
116
+ const parsed = JSON.parse(fs.readFileSync(filePath, 'utf8'));
117
+ return parsed.state || null;
118
+ } catch {
119
+ return null;
120
+ }
121
+ }
122
+
123
+ // The session's A/B arm written by the PostToolUse hook, keyed by the
124
+ // X-Claude-Code-Session-Id header Claude Code sends on every API request.
125
+ // A session property, so no freshness window. null when the header is
126
+ // missing/invalid or the session hasn't run a tool yet.
127
+ function _readSessionArm(sandboxStateDir, sessionId) {
128
+ try {
129
+ const file = sessionArmPath(sandboxStateDir, sessionId);
130
+ if (!file) return null;
131
+ const arm = JSON.parse(fs.readFileSync(file, 'utf8')).arm;
132
+ return arm === 'treated' || arm === 'holdout' ? arm : null;
133
+ } catch {
134
+ return null;
135
+ }
136
+ }
137
+
138
+ export function buildTroxyForwardHeaders(incomingHeaders, troxyKey, sandboxStateDir = _DEFAULT_SANDBOX_STATE_DIR) {
97
139
  const headers = {};
98
140
  for (const [k, v] of Object.entries(incomingHeaders)) {
99
141
  if (_HOP_BY_HOP_HEADERS.has(k) || k === 'authorization' || k === 'accept-encoding') continue;
@@ -104,6 +146,14 @@ export function buildTroxyForwardHeaders(incomingHeaders, troxyKey) {
104
146
  }
105
147
  const surface = detectSurface(incomingHeaders);
106
148
  if (surface) headers['x-troxy-surface'] = surface;
149
+ // With a session header, only that session's own arm counts (a session
150
+ // with no tool call yet stays untagged rather than borrowing another
151
+ // session's state). Legacy shared last-state only when there's no header.
152
+ const sessionFile = sessionArmPath(sandboxStateDir, incomingHeaders['x-claude-code-session-id']);
153
+ const sandboxState = sessionFile
154
+ ? _readSessionArm(sandboxStateDir, incomingHeaders['x-claude-code-session-id'])
155
+ : _readFreshSandboxState(sandboxStateDir);
156
+ if (sandboxState) headers['x-troxy-tool-sandbox'] = sandboxState;
107
157
  headers.authorization = `Bearer ${troxyKey}`;
108
158
  return headers;
109
159
  }
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,157 @@
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
+ // Per-session A/B arm. The measurement compares whole sessions, so the
10
+ // holdout draw happens once per Claude Code session_id, not per tool call:
11
+ // a per-call draw leaves both arms with near-identical context (every
12
+ // "holdout" request still carries earlier compressed outputs), which
13
+ // measures ~0 whatever the real effect is. The interceptor reads this same
14
+ // file by the X-Claude-Code-Session-Id request header to tag each request
15
+ // with its own session's arm.
16
+ // ponytail: one tiny file per session, never pruned; add cleanup if ~/.troxy/sandbox/sessions gets large.
17
+ const SESSION_ID_RE = /^[A-Za-z0-9-]{8,128}$/;
18
+
19
+ export function sessionArmPath(dir, sessionId) {
20
+ if (typeof sessionId !== 'string' || !SESSION_ID_RE.test(sessionId)) return null;
21
+ return path.join(dir, 'sessions', `${sessionId}.json`);
22
+ }
23
+
24
+ function sessionArm(dir, sessionId, holdoutFraction, randomFn) {
25
+ const file = sessionArmPath(dir, sessionId);
26
+ if (!file) return null;
27
+ try {
28
+ const arm = JSON.parse(fs.readFileSync(file, 'utf8')).arm;
29
+ if (arm === 'treated' || arm === 'holdout') return arm;
30
+ } catch { /* first tool call of this session, or unreadable: draw below */ }
31
+ const arm = randomFn() < holdoutFraction ? 'holdout' : 'treated';
32
+ try {
33
+ fs.mkdirSync(path.dirname(file), { recursive: true });
34
+ fs.writeFileSync(file, JSON.stringify({ arm }));
35
+ } catch {
36
+ return null; // can't persist the arm, so fall back to the per-call path
37
+ }
38
+ return arm;
39
+ }
40
+
41
+ function writeState(dir, state) {
42
+ try {
43
+ fs.mkdirSync(dir, { recursive: true });
44
+ fs.writeFileSync(path.join(dir, 'last-state.json'), JSON.stringify({ state }));
45
+ } catch {
46
+ // Fails open - a state-file write failure must never surface as a hook error.
47
+ }
48
+ }
49
+
50
+ // Real tool_response shapes, confirmed against this machine's own
51
+ // ~/.claude/projects/*/*.jsonl transcripts (not the invented `.output`
52
+ // field this file originally assumed - that field does not exist on any
53
+ // real tool result; see the 2026-10-05 whole-branch review for the
54
+ // incident this fixes). Bash/Bash-like tools dominate real traffic and
55
+ // carry the text in `stdout`; Read nests it under `file.content`; Write/
56
+ // Edit-created-file results carry it directly on `content`. Anything else
57
+ // (Grep's `matches` list, bare strings from MCP tools, structured Edit
58
+ // diffs, etc.) is left untouched rather than guessed at, since a wrong
59
+ // guess here means Claude Code rejects updatedToolOutput against the
60
+ // tool's own schema and reverts to the original output anyway - a correct
61
+ // no-op is strictly better than an incorrect one that also surfaces a
62
+ // visible hook error.
63
+ function extractText(toolResponse) {
64
+ if (typeof toolResponse === 'string') return toolResponse;
65
+ if (toolResponse && typeof toolResponse === 'object') {
66
+ if (typeof toolResponse.stdout === 'string') return toolResponse.stdout;
67
+ if (toolResponse.file && typeof toolResponse.file.content === 'string') return toolResponse.file.content;
68
+ if (typeof toolResponse.content === 'string') return toolResponse.content;
69
+ }
70
+ return null;
71
+ }
72
+
73
+ // Mirrors extractText's shape-matching exactly, replacing only the text
74
+ // field so the rest of the real object (stderr, filePath, structuredPatch,
75
+ // etc.) reaches Claude unchanged and passes the tool's own output schema.
76
+ function rebuildWithText(toolResponse, text) {
77
+ if (typeof toolResponse === 'string') return text;
78
+ if (toolResponse.file && typeof toolResponse.file.content === 'string') {
79
+ return { ...toolResponse, file: { ...toolResponse.file, content: text } };
80
+ }
81
+ if (typeof toolResponse.stdout === 'string') {
82
+ return { ...toolResponse, stdout: text };
83
+ }
84
+ return { ...toolResponse, content: text };
85
+ }
86
+
87
+ export async function buildHookResponse(toolUseBlock, {
88
+ sandboxFraction = 1,
89
+ holdoutFraction = 0.2,
90
+ randomFn = Math.random,
91
+ storeDir = defaultStoreDir(),
92
+ } = {}) {
93
+ const toolResponse = toolUseBlock?.tool_response;
94
+ const arm = sessionArm(storeDir, toolUseBlock?.session_id, holdoutFraction, randomFn);
95
+ if (arm === 'holdout') {
96
+ writeState(storeDir, 'holdout');
97
+ return undefined;
98
+ }
99
+ const output = extractText(toolResponse);
100
+ if (output === null || !shouldSandbox(output)) {
101
+ writeState(storeDir, 'skipped');
102
+ return undefined;
103
+ }
104
+
105
+ // No session arm (hook input without session_id): legacy per-call draw.
106
+ if (!arm && randomFn() < sandboxFraction && randomFn() < holdoutFraction) {
107
+ writeState(storeDir, 'holdout');
108
+ return undefined;
109
+ }
110
+ if (!arm && randomFn() >= sandboxFraction) {
111
+ writeState(storeDir, 'skipped');
112
+ return undefined;
113
+ }
114
+
115
+ try {
116
+ const summary = summarize(output);
117
+ const { handle } = await put(output, { summary, tags: [] }, storeDir);
118
+ writeState(storeDir, 'treated');
119
+ const updatedText = `${summary}\n\n(troxy_recall("${handle}") to fetch the full output)`;
120
+ return {
121
+ hookSpecificOutput: {
122
+ hookEventName: 'PostToolUse',
123
+ updatedToolOutput: rebuildWithText(toolResponse, updatedText),
124
+ },
125
+ };
126
+ } catch {
127
+ // Fail open: storage broke, show the real output rather than losing it.
128
+ writeState(storeDir, 'skipped');
129
+ return undefined;
130
+ }
131
+ }
132
+
133
+ async function readStdin(timeoutMs) {
134
+ return new Promise((resolve) => {
135
+ let data = '';
136
+ const timer = setTimeout(() => resolve(data), timeoutMs);
137
+ process.stdin.on('data', (chunk) => { data += chunk; });
138
+ process.stdin.on('end', () => { clearTimeout(timer); resolve(data); });
139
+ });
140
+ }
141
+
142
+ // Exported entry point - invoked both by bin/troxy.js's dynamic import
143
+ // (`troxy posttooluse-hook`, registered via patchClaudeCodeHooks in
144
+ // init.js) and, below, by this file's own direct-execution guard.
145
+ export async function runPostToolUseHook() {
146
+ try {
147
+ const raw = await readStdin(STDIN_TIMEOUT_MS);
148
+ const input = JSON.parse(raw);
149
+ const response = await buildHookResponse(input);
150
+ if (response) process.stdout.write(JSON.stringify(response));
151
+ } catch {
152
+ // Fail open on any parse/unexpected error - never block the real tool output.
153
+ }
154
+ process.exit(0);
155
+ }
156
+
157
+ 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
+ }