troxy-cli 1.29.24 → 1.29.26

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.26",
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
@@ -11,6 +11,32 @@ import { enabledProviders, interceptHostsFor } from './providers.js';
11
11
  import { probePort } from './proxy.js';
12
12
  import { launchdUserDomain } from './launchd.js';
13
13
  import { reclaimOwnership } from './sudo-ownership.js';
14
+ import { fileURLToPath } from 'url';
15
+ import { detectInstalledClaudeCodeVersion, supportsMods } from './mods/version-check.js';
16
+
17
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
18
+ const TROXY_GUARD_MOD_SOURCE_DIR = path.join(__dirname, 'mods', 'troxy-guard');
19
+
20
+ // mods-policy-engine plan (2026-10-06), Task 4. Mods need Claude Code >=2.1.287
21
+ // - installing unconditionally would silently do nothing (or worse, leave an
22
+ // inert directory) on anyone still on an older version (e.g. the Homebrew
23
+ // cask, which was still frozen at 2.1.285 as of 2026-10-06 even after
24
+ // `brew update` - see project_claude_code_mods_investigation memory). A
25
+ // plugin dropped into ~/.claude/skills/<name>/ loads automatically with zero
26
+ // flags and zero settings.json edits - verified directly against a real
27
+ // session, not assumed from docs.
28
+ export async function installTroxyGuardMod(home = os.homedir(), detectFn = detectInstalledClaudeCodeVersion) {
29
+ const version = await detectFn();
30
+ if (!version || !supportsMods(`${version.major}.${version.minor}.${version.patch}`)) return false;
31
+ const destDir = path.join(home, '.claude', 'skills', 'troxy-guard');
32
+ fs.rmSync(destDir, { recursive: true, force: true });
33
+ fs.cpSync(TROXY_GUARD_MOD_SOURCE_DIR, destDir, { recursive: true });
34
+ return true;
35
+ }
36
+
37
+ export function uninstallTroxyGuardMod(home = os.homedir()) {
38
+ fs.rmSync(path.join(home, '.claude', 'skills', 'troxy-guard'), { recursive: true, force: true });
39
+ }
14
40
 
15
41
  // `npx troxy-cli@latest <cmd>` puts a `troxy` shim on PATH only for that one
16
42
  // child process (npx's own temp-PATH trick) - it never installs a persistent
@@ -147,6 +173,14 @@ export async function reprovisionKeyConsumers(key, agentName, proxyOptIn = null,
147
173
  } catch (err) {
148
174
  console.log(` • Claude Code usage capture ✗ (${err.message})`);
149
175
  }
176
+ try {
177
+ const installed = await installTroxyGuardMod();
178
+ console.log(installed
179
+ ? ` • Troxy policy engine (terminal + Desktop) ✓`
180
+ : ` • Troxy policy engine - (needs Claude Code 2.1.287+, skipped)`);
181
+ } catch (err) {
182
+ console.log(` • Troxy policy engine ✗ (${err.message})`);
183
+ }
150
184
  }
151
185
  if (hasClaude) {
152
186
  // Terminal used to get base-URL substitution (maybeEnableClaudeCodeProxy)
@@ -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;
@@ -0,0 +1,5 @@
1
+ {
2
+ "name": "troxy-guard",
3
+ "version": "0.1.0",
4
+ "description": "Troxy's policy engine, enforcing your org's ALLOW/BLOCK/ESCALATE rules on every tool call."
5
+ }
@@ -0,0 +1 @@
1
+ { "modules": ["./register.js"] }
@@ -0,0 +1,158 @@
1
+ const POLICY_CHECK_URL = 'https://api.troxy.io/agent/policy-check';
2
+ const TIMEOUT_MS = 5000;
3
+
4
+ function toDomainAndFields(e) {
5
+ // Mapping a real Bash command to structured policy fields (which domain,
6
+ // which action_verb/resource) is deliberately out of scope for this first
7
+ // build - see the plan's "Explicitly out of scope" section. Returning null
8
+ // here is a safe no-op: the existing proxy remains the backstop for every
9
+ // real Bash action until a later plan adds real command classification.
10
+ return null;
11
+ }
12
+
13
+ async function guardBash($, e, next) {
14
+ const mapped = _internal.toDomainAndFields(e);
15
+ if (!mapped) return next(e);
16
+
17
+ const controller = new AbortController();
18
+ const timer = setTimeout(() => controller.abort(), TIMEOUT_MS);
19
+ let result;
20
+ try {
21
+ const res = await $.http.fetch(POLICY_CHECK_URL, {
22
+ method: 'POST',
23
+ headers: { 'content-type': 'application/json' },
24
+ body: JSON.stringify({ domain: mapped.domain, fields: mapped.fields }),
25
+ signal: controller.signal,
26
+ });
27
+ result = res.ok ? await res.json() : { decision: 'ESCALATE', reason: 'policy service returned an error' };
28
+ } finally {
29
+ clearTimeout(timer);
30
+ }
31
+
32
+ if (result.decision === 'BLOCK') {
33
+ return { deny: 'Blocked by your org\'s Troxy policy' + (result.policy_id ? ' (' + result.policy_id + ')' : '') };
34
+ }
35
+ if (result.decision === 'ESCALATE') {
36
+ const answer = await $.ui.ask('Troxy: ' + (result.reason || 'this action needs your approval') + '. Allow it?', { choices: ['Allow', 'Deny'] });
37
+ if (answer !== 'Allow') return { deny: 'Denied by you after a Troxy escalation.' };
38
+ }
39
+ return next(e);
40
+ }
41
+
42
+ async function failClosed($, e, next, err) {
43
+ // Registered via .catch() below - this runs if guardBash throws, or if
44
+ // Claude Code decides the hook exceeded its time budget and would otherwise
45
+ // just skip it. A skipped policy hook must never silently mean "allow".
46
+ return { deny: 'Troxy could not evaluate this action (' + err.message + ') - denied by default rather than allowed.' };
47
+ }
48
+
49
+ // --- Routing: extend the usage bar by sending easy prompts to a cheaper model.
50
+ //
51
+ // Ported from the proxy's scorer (troxy-tf-live model-proxy/app.py: bands,
52
+ // never-upward rule, 50k context floor for Haiku). The mod scores each human
53
+ // prompt once with the user's own session via $.model.complete, so there is no
54
+ // Troxy round trip and no credential to carry. One decision per prompt, applied
55
+ // to every step of its turn, so the cache prefix stays stable within a turn.
56
+
57
+ const MODELS = {
58
+ haiku: 'claude-haiku-4-5-20251001',
59
+ sonnet: 'claude-sonnet-5-5',
60
+ opus: 'claude-opus-5-5',
61
+ };
62
+ const RANK = { haiku: 0, sonnet: 1, opus: 2 };
63
+ const BANDS = [[0.1, 'haiku'], [0.6, 'sonnet'], [0.8, 'opus']];
64
+ const HAIKU_CONTEXT_FLOOR_TOKENS = 50000;
65
+ const SCORE_TIMEOUT_MS = 3000;
66
+ const SCORER_PROMPT_CHARS = 4000;
67
+ const SCORER_SYSTEM = 'You are a routing classifier for a coding-assistant proxy. Read the request below '
68
+ + 'and reply with ONLY a number between 0.0 and 1.0 estimating how much reasoning or '
69
+ + 'capability it needs: 0.0 for trivial/routine work a small fast model handles fine, '
70
+ + '1.0 for something that genuinely needs the most capable model available. '
71
+ + 'Reply with the number and nothing else - no words, no punctuation.';
72
+
73
+ function tierOf(model) {
74
+ const m = String(model || '').toLowerCase();
75
+ for (const t of Object.keys(RANK)) if (m.includes(t)) return t;
76
+ return null;
77
+ }
78
+
79
+ function parseScore(text) {
80
+ const m = /\d*\.?\d+/.exec(String(text || ''));
81
+ if (!m) return null;
82
+ const n = Number(m[0]);
83
+ return Number.isFinite(n) ? Math.min(1, Math.max(0, n)) : null;
84
+ }
85
+
86
+ function pickModel(score, requested, contextTokens) {
87
+ const rq = tierOf(requested);
88
+ if (rq === null) return requested; // unknown family (e.g. a newer tier): leave it alone
89
+ let tier = rq;
90
+ for (const [limit, name] of BANDS) {
91
+ if (score <= limit) { if (RANK[name] < RANK[rq]) tier = name; break; }
92
+ }
93
+ if (tier === 'haiku' && rq !== 'haiku' && contextTokens > HAIKU_CONTEXT_FLOOR_TOKENS) {
94
+ tier = RANK.sonnet < RANK[rq] ? 'sonnet' : rq;
95
+ }
96
+ return tier === rq ? requested : MODELS[tier];
97
+ }
98
+
99
+ // /troxy off|on|status. Pure so it can be tested; the hook below stores the result.
100
+ function troxyCommand(args, paused) {
101
+ const a = String(args || '').trim().toLowerCase();
102
+ if (a === 'off') return { paused: true, text: 'Troxy routing is off. Your chosen model is used as-is. Type /troxy on to resume.' };
103
+ if (a === 'on') return { paused: false, text: 'Troxy routing is on.' };
104
+ return { paused, text: paused ? 'Troxy routing is off. Type /troxy on to resume.' : 'Troxy routing is on. Type /troxy off to pause it.' };
105
+ }
106
+
107
+ // Whether this session has shown its one "Troxy is active" notice yet.
108
+ let announced = false;
109
+
110
+ // The decision for the turn in flight: { score, tokens } or null (no change).
111
+ let decision = null;
112
+
113
+ async function scorePrompt($, e, next) {
114
+ decision = null;
115
+ if (await $.store.get('paused')) return next(e);
116
+ const kind = e.origin && e.origin.kind;
117
+ if (kind !== 'composer' && kind !== 'bridge' && kind !== 'sdk') return next(e);
118
+ const r = await $.model.complete(
119
+ { model: 'haiku', system: SCORER_SYSTEM, prompt: e.text.slice(0, SCORER_PROMPT_CHARS), maxTokens: 8, effort: 'low', timeoutMs: SCORE_TIMEOUT_MS },
120
+ );
121
+ const score = r.isAnswered ? parseScore(r.text) : null;
122
+ if (score !== null) {
123
+ const u = await $.session.usage();
124
+ decision = { score, tokens: u.context ? u.context.tokens : 0 };
125
+ }
126
+ return next(e);
127
+ }
128
+
129
+ async function* routeStep($, e, next) {
130
+ // Main loop only: a subagent keeps whatever model it was given.
131
+ if (!decision || e.agentId) return yield* next(e);
132
+ const model = pickModel(decision.score, e.model, decision.tokens);
133
+ if (model !== e.model && !announced) {
134
+ announced = true;
135
+ $.ui.toast('Troxy is active');
136
+ }
137
+ return yield* next(model === e.model ? e : { ...e, model });
138
+ }
139
+
140
+ export function register(on) {
141
+ on('tool.call', { tool: 'Bash' }, guardBash).catch(failClosed);
142
+ on('session.start', async ($, e, next) => {
143
+ await $.command.register({ name: 'troxy', description: 'Pause or resume Troxy model routing', argumentHint: '[on|off|status]' });
144
+ return next(e);
145
+ });
146
+ on('command.run', { command: 'troxy' }, async ($, e) => {
147
+ const r = troxyCommand(e.args, Boolean(await $.store.get('paused')));
148
+ await $.store.set('paused', r.paused);
149
+ return { text: r.text };
150
+ });
151
+ // Routing fails open: a scorer or hook error must never break the session,
152
+ // it just means the requested model is used as-is.
153
+ on('prompt.submit', scorePrompt).catch(($, e, next) => { decision = null; return next(e); });
154
+ on('turn.step', routeStep).catch(async function* ($, e, next) { return yield* next(e); });
155
+ }
156
+
157
+ // Exported for tests only - not part of the mod's runtime contract.
158
+ export const _internal = { toDomainAndFields, guardBash, failClosed, parseScore, pickModel, troxyCommand, MODELS };
@@ -0,0 +1,3 @@
1
+ {
2
+ "extends": "./.claude-plugin/types/tsconfig.json"
3
+ }
@@ -0,0 +1,24 @@
1
+ import { execFileSync } from 'node:child_process';
2
+
3
+ export function supportsMods(versionString) {
4
+ const m = /^(\d+)\.(\d+)\.(\d+)/.exec(String(versionString || '').trim());
5
+ if (!m) return false;
6
+ const actual = [Number(m[1]), Number(m[2]), Number(m[3])];
7
+ const floor = [2, 1, 287];
8
+ for (let i = 0; i < 3; i++) {
9
+ if (actual[i] > floor[i]) return true;
10
+ if (actual[i] < floor[i]) return false;
11
+ }
12
+ return true;
13
+ }
14
+
15
+ export async function detectInstalledClaudeCodeVersion(execFn = execFileSync) {
16
+ try {
17
+ const out = execFn('claude', ['--version'], { encoding: 'utf8' });
18
+ const m = /^(\d+)\.(\d+)\.(\d+)/.exec(out.trim());
19
+ if (!m) return null;
20
+ return { major: Number(m[1]), minor: Number(m[2]), patch: Number(m[3]) };
21
+ } catch {
22
+ return null;
23
+ }
24
+ }
@@ -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
  }
@@ -0,0 +1,76 @@
1
+ import { test } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import os from 'node:os';
4
+ import path from 'node:path';
5
+ import fs from 'node:fs';
6
+ import { installTroxyGuardMod, uninstallTroxyGuardMod } from '../init.js';
7
+
8
+ test('installTroxyGuardMod: copies the mod when the version supports it', async () => {
9
+ const tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'troxy-mod-home-'));
10
+ try {
11
+ const installed = await installTroxyGuardMod(tmpHome, async () => ({ major: 2, minor: 1, patch: 289 }));
12
+ assert.equal(installed, true);
13
+ assert.ok(fs.existsSync(path.join(tmpHome, '.claude', 'skills', 'troxy-guard', '.claude-plugin', 'plugin.json')));
14
+ assert.ok(fs.existsSync(path.join(tmpHome, '.claude', 'skills', 'troxy-guard', 'hooks', 'register.js')));
15
+ } finally {
16
+ fs.rmSync(tmpHome, { recursive: true, force: true });
17
+ }
18
+ });
19
+
20
+ test('installTroxyGuardMod: skips silently below the version floor', async () => {
21
+ const tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'troxy-mod-home-'));
22
+ try {
23
+ const installed = await installTroxyGuardMod(tmpHome, async () => ({ major: 2, minor: 1, patch: 285 }));
24
+ assert.equal(installed, false);
25
+ assert.ok(!fs.existsSync(path.join(tmpHome, '.claude', 'skills', 'troxy-guard')));
26
+ } finally {
27
+ fs.rmSync(tmpHome, { recursive: true, force: true });
28
+ }
29
+ });
30
+
31
+ test('installTroxyGuardMod: skips silently when Claude Code is not found at all', async () => {
32
+ const tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'troxy-mod-home-'));
33
+ try {
34
+ const installed = await installTroxyGuardMod(tmpHome, async () => null);
35
+ assert.equal(installed, false);
36
+ assert.ok(!fs.existsSync(path.join(tmpHome, '.claude', 'skills', 'troxy-guard')));
37
+ } finally {
38
+ fs.rmSync(tmpHome, { recursive: true, force: true });
39
+ }
40
+ });
41
+
42
+ test('installTroxyGuardMod: re-running replaces a stale copy rather than erroring on an existing directory', async () => {
43
+ const tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'troxy-mod-home-'));
44
+ try {
45
+ const destDir = path.join(tmpHome, '.claude', 'skills', 'troxy-guard');
46
+ fs.mkdirSync(destDir, { recursive: true });
47
+ fs.writeFileSync(path.join(destDir, 'stale-leftover-file.txt'), 'from a previous version');
48
+ await installTroxyGuardMod(tmpHome, async () => ({ major: 2, minor: 1, patch: 289 }));
49
+ assert.ok(fs.existsSync(path.join(destDir, '.claude-plugin', 'plugin.json')));
50
+ assert.ok(!fs.existsSync(path.join(destDir, 'stale-leftover-file.txt')), 'expected the stale file to be gone after a clean reinstall');
51
+ } finally {
52
+ fs.rmSync(tmpHome, { recursive: true, force: true });
53
+ }
54
+ });
55
+
56
+ test('uninstallTroxyGuardMod: removes the installed mod directory', () => {
57
+ const tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'troxy-mod-home-'));
58
+ try {
59
+ const destDir = path.join(tmpHome, '.claude', 'skills', 'troxy-guard');
60
+ fs.mkdirSync(destDir, { recursive: true });
61
+ fs.writeFileSync(path.join(destDir, 'plugin.json'), '{}');
62
+ uninstallTroxyGuardMod(tmpHome);
63
+ assert.ok(!fs.existsSync(destDir));
64
+ } finally {
65
+ fs.rmSync(tmpHome, { recursive: true, force: true });
66
+ }
67
+ });
68
+
69
+ test('uninstallTroxyGuardMod: a no-op (not a throw) when nothing was installed', () => {
70
+ const tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'troxy-mod-home-'));
71
+ try {
72
+ assert.doesNotThrow(() => uninstallTroxyGuardMod(tmpHome));
73
+ } finally {
74
+ fs.rmSync(tmpHome, { recursive: true, force: true });
75
+ }
76
+ });
@@ -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
+ });
@@ -0,0 +1,63 @@
1
+ import { strict as assert } from 'assert';
2
+ import { test } from 'node:test';
3
+ import os from 'node:os';
4
+ import path from 'node:path';
5
+ import fs from 'node:fs';
6
+ import { fileURLToPath } from 'node:url';
7
+ import { execFileSync } from 'node:child_process';
8
+ import { installTroxyGuardMod } from '../init.js';
9
+
10
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
11
+ const initJsPath = path.join(__dirname, '..', 'init.js');
12
+
13
+ test('on an unsupported Claude Code version, a real troxy init completes and installs no mod, with no error', () => {
14
+ // A fake `claude` reporting 2.1.285 (below the 2.1.287 floor) sits first on
15
+ // PATH, so this does not depend on which Claude Code the machine has installed.
16
+ const tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'troxy-mods-fallback-'));
17
+ try {
18
+ const fakeBin = path.join(tmpHome, 'fakebin');
19
+ fs.mkdirSync(fakeBin);
20
+ fs.writeFileSync(path.join(fakeBin, 'claude'), '#!/bin/sh\necho "2.1.285 (Claude Code)"\n', { mode: 0o755 });
21
+ fs.mkdirSync(path.join(tmpHome, '.config', 'Claude'), { recursive: true });
22
+ const desktopConfigPath =
23
+ process.platform === 'darwin'
24
+ ? path.join(tmpHome, 'Library', 'Application Support', 'Claude', 'claude_desktop_config.json')
25
+ : path.join(tmpHome, '.config', 'claude', 'claude_desktop_config.json');
26
+ fs.mkdirSync(path.dirname(desktopConfigPath), { recursive: true });
27
+ fs.writeFileSync(desktopConfigPath, '{}');
28
+
29
+ const script = `
30
+ import { reprovisionKeyConsumers } from ${JSON.stringify('file://' + initJsPath)};
31
+ await reprovisionKeyConsumers('test-api-key-ci', 'claude-code', false, false);
32
+ `;
33
+ const scriptPath = path.join(tmpHome, 'run.mjs');
34
+ fs.writeFileSync(scriptPath, script);
35
+
36
+ assert.doesNotThrow(() => {
37
+ execFileSync(process.execPath, [scriptPath], {
38
+ env: { ...process.env, HOME: tmpHome, PATH: `${fakeBin}${path.delimiter}${process.env.PATH}`, TROXY_TEST_SKIP_SERVICE_INSTALL: '1' },
39
+ encoding: 'utf8',
40
+ });
41
+ }, 'expected a real troxy init run to complete without throwing, even though this machine is below the mods version floor');
42
+
43
+ assert.ok(
44
+ !fs.existsSync(path.join(tmpHome, '.claude', 'skills', 'troxy-guard')),
45
+ 'expected no mod directory on a version below the floor',
46
+ );
47
+ } finally {
48
+ fs.rmSync(tmpHome, { recursive: true, force: true });
49
+ }
50
+ });
51
+
52
+ test('with the mod absent entirely (the org-lockdown case), installTroxyGuardMod leaves zero files behind - the proxy remains the sole backstop', async () => {
53
+ // Duplicated deliberately from Task 4's own test, as this plan's named
54
+ // Review Focus regression, so it survives independently of that file.
55
+ const tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'troxy-mods-fallback-'));
56
+ try {
57
+ const installed = await installTroxyGuardMod(tmpHome, async () => null);
58
+ assert.equal(installed, false);
59
+ assert.ok(!fs.existsSync(path.join(tmpHome, '.claude', 'skills', 'troxy-guard')));
60
+ } finally {
61
+ fs.rmSync(tmpHome, { recursive: true, force: true });
62
+ }
63
+ });
@@ -0,0 +1,54 @@
1
+ import { test } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { _internal } from '../mods/troxy-guard/hooks/register.js';
4
+
5
+ const { parseScore, pickModel, troxyCommand, MODELS } = _internal;
6
+
7
+ test('parseScore: reads a bare number and clamps to 0..1', () => {
8
+ assert.equal(parseScore('0.35'), 0.35);
9
+ assert.equal(parseScore(' 0.8\n'), 0.8);
10
+ assert.equal(parseScore('1.7'), 1);
11
+ assert.equal(parseScore('Score: 0.2'), 0.2);
12
+ });
13
+
14
+ test('parseScore: unparseable text is null so the caller keeps the requested model', () => {
15
+ assert.equal(parseScore(''), null);
16
+ assert.equal(parseScore('hard'), null);
17
+ });
18
+
19
+ test('pickModel: bands route down from opus (<=0.1 haiku, <=0.6 sonnet, <=0.8 opus)', () => {
20
+ assert.equal(pickModel(0.05, MODELS.opus, 1000), MODELS.haiku);
21
+ assert.equal(pickModel(0.4, MODELS.opus, 1000), MODELS.sonnet);
22
+ assert.equal(pickModel(0.7, MODELS.opus, 1000), MODELS.opus);
23
+ assert.equal(pickModel(0.95, MODELS.opus, 1000), MODELS.opus);
24
+ });
25
+
26
+ test('pickModel: never routes upward', () => {
27
+ assert.equal(pickModel(0.95, MODELS.sonnet, 1000), MODELS.sonnet);
28
+ assert.equal(pickModel(0.7, MODELS.haiku, 1000), MODELS.haiku);
29
+ assert.equal(pickModel(0.4, MODELS.haiku, 1000), MODELS.haiku);
30
+ });
31
+
32
+ test('pickModel: no haiku once the context passes the 50k floor, sonnet instead', () => {
33
+ assert.equal(pickModel(0.05, MODELS.opus, 60000), MODELS.sonnet);
34
+ assert.equal(pickModel(0.05, MODELS.sonnet, 60000), MODELS.sonnet);
35
+ assert.equal(pickModel(0.05, MODELS.haiku, 60000), MODELS.haiku);
36
+ });
37
+
38
+ test('pickModel: a model it does not recognise is left alone', () => {
39
+ assert.equal(pickModel(0.05, 'some-other-model', 1000), 'some-other-model');
40
+ });
41
+
42
+ test('pickModel: a [1m] suffix is understood when comparing tiers', () => {
43
+ assert.equal(pickModel(0.4, MODELS.opus + '[1m]', 1000), MODELS.sonnet);
44
+ assert.equal(pickModel(0.95, MODELS.opus + '[1m]', 1000), MODELS.opus + '[1m]');
45
+ });
46
+
47
+ test('troxyCommand: off and on flip the setting, status and anything else only report it', () => {
48
+ assert.deepEqual(troxyCommand('off', false), { paused: true, text: 'Troxy routing is off. Your chosen model is used as-is. Type /troxy on to resume.' });
49
+ assert.deepEqual(troxyCommand(' ON ', true), { paused: false, text: 'Troxy routing is on.' });
50
+ assert.equal(troxyCommand('status', true).paused, true);
51
+ assert.match(troxyCommand('', true).text, /off/);
52
+ assert.match(troxyCommand('status', false).text, /on/);
53
+ assert.equal(troxyCommand('nonsense', false).paused, false);
54
+ });
@@ -0,0 +1,64 @@
1
+ import { test } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import os from 'node:os';
4
+ import path from 'node:path';
5
+ import fs from 'node:fs';
6
+ import { fileURLToPath } from 'node:url';
7
+ import { execFileSync } from 'node:child_process';
8
+ import { _internal } from '../mods/troxy-guard/hooks/register.js';
9
+
10
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
11
+ const MOD_DIR = path.join(__dirname, '..', 'mods', 'troxy-guard');
12
+
13
+ // --- Unit tests of the guard logic itself (fast, no subprocess needed) ---
14
+ //
15
+ // checkPolicy is deliberately NOT extracted as its own function: `claude
16
+ // plugin validate` enforces that $ is only ever used inline at its own call
17
+ // site (`$.noun.event(...)`), never forwarded as an argument to a helper -
18
+ // an early version of this file failed validation for exactly that. That
19
+ // collapses the testable surface here to toDomainAndFields and failClosed;
20
+ // the BLOCK/ESCALATE/ALLOW branches are exercised by the real subprocess
21
+ // test below instead, against a real $.http.fetch call.
22
+
23
+ function fakeNext(e) { return Promise.resolve({ _passedThrough: e }); }
24
+
25
+ test('guardBash: unmapped command (current real scope) passes through unchanged', async () => {
26
+ const result = await _internal.guardBash({}, { marker: 3 }, fakeNext);
27
+ assert.deepEqual(result, { _passedThrough: { marker: 3 } });
28
+ });
29
+
30
+ test('failClosed: denies with the error message rather than throwing or allowing', async () => {
31
+ const result = await _internal.failClosed({}, {}, fakeNext, new Error('fetch failed'));
32
+ assert.ok(result.deny.includes('fetch failed'));
33
+ });
34
+
35
+ // --- Real subprocess test: the mod actually loads and Bash passes through ---
36
+
37
+ test('real subprocess: the mod loads via --plugin-dir and claude plugin validate recognizes the tool.call hook', () => {
38
+ const out = execFileSync('npx', ['-y', '@anthropic-ai/claude-code@2.1.289', 'plugin', 'validate', MOD_DIR], {
39
+ encoding: 'utf8',
40
+ timeout: 60000,
41
+ });
42
+ assert.ok(out.includes('tool.call'), `expected the validator to list a tool.call hook, got: ${out}`);
43
+ });
44
+
45
+ test('real subprocess: a real Bash command still runs normally through the mod (current scope: always passes through)', { skip: !fs.existsSync(path.join(os.homedir(), '.claude.json')) && 'no local Claude Code login available - this test needs a real, already-authenticated session (see project_claude_code_mods_investigation memory: a copied .claude.json alone was not sufficient to authenticate a fresh HOME in this environment, so this runs against the real HOME instead of an isolated one)' }, () => {
46
+ const workdir = fs.mkdtempSync(path.join(os.tmpdir(), 'troxy-guard-e2e-workdir-'));
47
+ try {
48
+ fs.mkdirSync(path.join(workdir, '.claude'), { recursive: true });
49
+ fs.writeFileSync(
50
+ path.join(workdir, '.claude', 'settings.local.json'),
51
+ JSON.stringify({ permissions: { allow: ['Bash(echo:*)'] } }),
52
+ );
53
+ const out = execFileSync(
54
+ 'npx',
55
+ ['-y', '@anthropic-ai/claude-code@2.1.289', '--plugin-dir', MOD_DIR, '-p',
56
+ 'Run this exact bash command: echo TROXY_GUARD_PASSTHROUGH_TEST', '--output-format', 'json'],
57
+ { cwd: workdir, encoding: 'utf8', timeout: 90000 },
58
+ );
59
+ const result = JSON.parse(out);
60
+ assert.equal(result.is_error, false);
61
+ } finally {
62
+ fs.rmSync(workdir, { recursive: true, force: true });
63
+ }
64
+ });
@@ -0,0 +1,44 @@
1
+ import { test } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { supportsMods, detectInstalledClaudeCodeVersion } from '../mods/version-check.js';
4
+
5
+ test('supportsMods: below floor returns false', () => {
6
+ assert.equal(supportsMods('2.1.285 (Claude Code)'), false);
7
+ });
8
+
9
+ test('supportsMods: at floor returns true', () => {
10
+ assert.equal(supportsMods('2.1.287 (Claude Code)'), true);
11
+ });
12
+
13
+ test('supportsMods: above floor (minor bump) returns true', () => {
14
+ assert.equal(supportsMods('2.2.0 (Claude Code)'), true);
15
+ });
16
+
17
+ test('supportsMods: above floor (patch bump) returns true', () => {
18
+ assert.equal(supportsMods('2.1.289 (Claude Code)'), true);
19
+ });
20
+
21
+ test('supportsMods: below floor (lower major) returns false', () => {
22
+ assert.equal(supportsMods('1.9.999 (Claude Code)'), false);
23
+ });
24
+
25
+ test('supportsMods: unparseable string returns false, not a throw', () => {
26
+ assert.equal(supportsMods('not a version'), false);
27
+ });
28
+
29
+ test('supportsMods: empty/undefined returns false', () => {
30
+ assert.equal(supportsMods(undefined), false);
31
+ assert.equal(supportsMods(''), false);
32
+ });
33
+
34
+ test('detectInstalledClaudeCodeVersion: parses a real-shaped execFn result', async () => {
35
+ const fakeExec = () => '2.1.289 (Claude Code)\n';
36
+ const result = await detectInstalledClaudeCodeVersion(fakeExec);
37
+ assert.deepEqual(result, { major: 2, minor: 1, patch: 289 });
38
+ });
39
+
40
+ test('detectInstalledClaudeCodeVersion: returns null, not a throw, when the command fails', async () => {
41
+ const fakeExec = () => { throw new Error('command not found: claude'); };
42
+ const result = await detectInstalledClaudeCodeVersion(fakeExec);
43
+ assert.equal(result, null);
44
+ });
@@ -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
+ });
package/src/uninstall.js CHANGED
@@ -9,6 +9,7 @@ import {
9
9
  claudeCodeConfigPath, claudeCodeSettingsPath,
10
10
  unpatchClaudeCodeConfig, _unpatchClaudeCodeHooksFile,
11
11
  unpatchClaudeCodeProxy, unpatchClaudeCodeInterception,
12
+ uninstallTroxyGuardMod,
12
13
  } from './init.js';
13
14
  import { launchdUserDomain } from './launchd.js';
14
15
 
@@ -147,6 +148,7 @@ export async function runUninstall() {
147
148
  _unpatchClaudeCodeHooksFile(claudeCodeSettingsPath());
148
149
  unpatchClaudeCodeProxy(claudeCodeSettingsPath());
149
150
  unpatchClaudeCodeInterception(claudeCodeSettingsPath());
151
+ uninstallTroxyGuardMod();
150
152
  console.log('✓');
151
153
  } catch (err) {
152
154
  console.log('✗');