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.
|
|
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/interceptor.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
+
});
|