troxy-cli 1.29.24 → 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "troxy-cli",
3
- "version": "1.29.24",
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": {
@@ -33,6 +33,7 @@ import https from 'node:https';
33
33
  import fs from 'node:fs';
34
34
  import path from 'node:path';
35
35
  import os from 'node:os';
36
+ import { sessionArmPath } from './sandbox/posttooluse-hook.js';
36
37
 
37
38
  // Found live 2026-09-08, first real terminal validation of this whole
38
39
  // mechanism: a real `claude` process opens a burst of ~15 concurrent
@@ -119,6 +120,21 @@ function _readFreshSandboxState(sandboxStateDir) {
119
120
  }
120
121
  }
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
+
122
138
  export function buildTroxyForwardHeaders(incomingHeaders, troxyKey, sandboxStateDir = _DEFAULT_SANDBOX_STATE_DIR) {
123
139
  const headers = {};
124
140
  for (const [k, v] of Object.entries(incomingHeaders)) {
@@ -130,7 +146,13 @@ export function buildTroxyForwardHeaders(incomingHeaders, troxyKey, sandboxState
130
146
  }
131
147
  const surface = detectSurface(incomingHeaders);
132
148
  if (surface) headers['x-troxy-surface'] = surface;
133
- const sandboxState = _readFreshSandboxState(sandboxStateDir);
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);
134
156
  if (sandboxState) headers['x-troxy-tool-sandbox'] = sandboxState;
135
157
  headers.authorization = `Bearer ${troxyKey}`;
136
158
  return headers;
@@ -6,6 +6,38 @@ import { put, storeDir as defaultStoreDir } from './store.js';
6
6
 
7
7
  const STDIN_TIMEOUT_MS = 2000;
8
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
+
9
41
  function writeState(dir, state) {
10
42
  try {
11
43
  fs.mkdirSync(dir, { recursive: true });
@@ -59,17 +91,23 @@ export async function buildHookResponse(toolUseBlock, {
59
91
  storeDir = defaultStoreDir(),
60
92
  } = {}) {
61
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
+ }
62
99
  const output = extractText(toolResponse);
63
100
  if (output === null || !shouldSandbox(output)) {
64
101
  writeState(storeDir, 'skipped');
65
102
  return undefined;
66
103
  }
67
104
 
68
- if (randomFn() < sandboxFraction && randomFn() < holdoutFraction) {
105
+ // No session arm (hook input without session_id): legacy per-call draw.
106
+ if (!arm && randomFn() < sandboxFraction && randomFn() < holdoutFraction) {
69
107
  writeState(storeDir, 'holdout');
70
108
  return undefined;
71
109
  }
72
- if (randomFn() >= sandboxFraction) {
110
+ if (!arm && randomFn() >= sandboxFraction) {
73
111
  writeState(storeDir, 'skipped');
74
112
  return undefined;
75
113
  }
@@ -653,3 +653,23 @@ describe('createInterceptor - integration, real sockets', () => {
653
653
  assert.equal(result.body, 'tunneled-because-cert-unhealthy');
654
654
  });
655
655
  });
656
+
657
+ describe('buildTroxyForwardHeaders session arm', () => {
658
+ it('tags a request with its own session arm, ignoring another session\'s last-state', () => {
659
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'troxy-sandbox-'));
660
+ fs.mkdirSync(path.join(dir, 'sessions'));
661
+ fs.writeFileSync(path.join(dir, 'sessions', 'sess-aaaa-1111.json'), JSON.stringify({ arm: 'holdout' }));
662
+ fs.writeFileSync(path.join(dir, 'last-state.json'), JSON.stringify({ state: 'treated' }));
663
+ const headers = buildTroxyForwardHeaders(
664
+ { host: 'api.anthropic.com', 'x-claude-code-session-id': 'sess-aaaa-1111' }, 'txy-key', dir);
665
+ assert.equal(headers['x-troxy-tool-sandbox'], 'holdout');
666
+ });
667
+
668
+ it('leaves a session with no arm yet untagged, never borrowing another session\'s last-state', () => {
669
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'troxy-sandbox-'));
670
+ fs.writeFileSync(path.join(dir, 'last-state.json'), JSON.stringify({ state: 'treated' }));
671
+ const headers = buildTroxyForwardHeaders(
672
+ { host: 'api.anthropic.com', 'x-claude-code-session-id': 'sess-new-9999' }, 'txy-key', dir);
673
+ assert.equal('x-troxy-tool-sandbox' in headers, false);
674
+ });
675
+ });
@@ -197,3 +197,29 @@ test('bin/troxy.js posttooluse-hook dispatch: real CLI subprocess does not throw
197
197
  fs.rmSync(tmpHome, { recursive: true, force: true });
198
198
  }
199
199
  });
200
+
201
+ test('session arm is drawn once per session_id and sticks across tool calls', async () => {
202
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'troxy-arm-'));
203
+ const big = { tool_response: { stdout: 'x'.repeat(5000) }, session_id: 'sess-aaaa-1111' };
204
+ // First call draws holdout (random 0 < 0.2).
205
+ const r1 = await buildHookResponse(big, { holdoutFraction: 0.2, randomFn: () => 0, storeDir: dir });
206
+ assert.equal(r1, undefined);
207
+ // Later draws would say treated, but the session stays holdout.
208
+ const r2 = await buildHookResponse(big, { holdoutFraction: 0.2, randomFn: () => 0.99, storeDir: dir });
209
+ assert.equal(r2, undefined);
210
+ assert.equal(JSON.parse(fs.readFileSync(path.join(dir, 'sessions', 'sess-aaaa-1111.json'), 'utf8')).arm, 'holdout');
211
+
212
+ // A treated session compresses every big output, no per-call holdout draw.
213
+ const t = { ...big, session_id: 'sess-bbbb-2222' };
214
+ const t1 = await buildHookResponse(t, { holdoutFraction: 0.2, randomFn: () => 0.99, storeDir: dir });
215
+ assert.ok(t1?.hookSpecificOutput);
216
+ const t2 = await buildHookResponse(t, { holdoutFraction: 0.2, randomFn: () => 0, storeDir: dir });
217
+ assert.ok(t2?.hookSpecificOutput, 'treated session must not fall into a per-call holdout');
218
+ });
219
+
220
+ test('session_id with path characters is ignored (no file outside the store)', async () => {
221
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'troxy-arm-'));
222
+ await buildHookResponse({ tool_response: { stdout: 'small' }, session_id: '../../etc/x' },
223
+ { holdoutFraction: 0.2, randomFn: () => 0, storeDir: dir });
224
+ assert.equal(fs.existsSync(path.join(dir, 'sessions')), false);
225
+ });