troxy-cli 1.29.5 → 1.29.6

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.5",
3
+ "version": "1.29.6",
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": {
@@ -0,0 +1,108 @@
1
+ // Token Optimization plan, Step 4 (real scope, not the artifact's original
2
+ // one): rewrite a small, explicit set of known-verbose Bash commands into
3
+ // their own natively-concise equivalent, via PreToolUse's `updatedInput`
4
+ // field - BEFORE the command runs, since that's the only rewrite point
5
+ // Claude Code's real hook contract offers. There is no PostToolUse field
6
+ // that replaces a tool's actual output (confirmed against Claude Code's
7
+ // hooks reference, 2026-09-13) - a hook can only add supplementary
8
+ // `additionalContext` alongside the original output, or block the call
9
+ // entirely and feed Claude a reason instead of a result. So "compact the
10
+ // output after it runs" is not buildable here at all; this only ever
11
+ // rewrites the INPUT of a command whose own concise flag produces
12
+ // equivalent information in less text.
13
+ //
14
+ // Deliberately small and conservative, not exhaustive: every rule here is
15
+ // a pure reformat (git status --porcelain carries the same file-state
16
+ // information as the verbose default, just machine-shaped) or a noise
17
+ // reduction of genuinely redundant detail (pytest's -q drops per-test
18
+ // PASSED lines, not failure detail) - never a rule that could cause an
19
+ // agent to act on less information than it would have had. A rule that
20
+ // trades size for real information loss (git log --oneline drops full
21
+ // commit messages and authorship) does NOT belong here even though it
22
+ // would shrink output - see docs/superpowers/specs for the fuller set of
23
+ // commands considered and explicitly excluded for that reason.
24
+ //
25
+ // Pure and synchronous on purpose: called from pretooluse-hook.js's hot
26
+ // path, which must fail open fast (see that file's own docstring) - no
27
+ // network calls, no I/O, nothing that can hang or throw asynchronously.
28
+
29
+ // Each rule: `test(command)` returns true if this rule applies; `rewrite`
30
+ // returns the new command string. Order matters - first match wins.
31
+ const _RULES = [
32
+ {
33
+ name: 'git-status-porcelain',
34
+ // Bare `git status`, optionally with a pathspec, but not already
35
+ // carrying its own format flag - regex, not a shell parser, so
36
+ // deliberately narrow: only matches when `git status` is the whole
37
+ // command (optionally followed by plain word/path arguments), never
38
+ // inside a compound command (&&, ;, |) where naive rewriting could
39
+ // easily target the wrong part of the line. Missing a rewrite
40
+ // opportunity is harmless; rewriting the wrong thing is not.
41
+ test(command) {
42
+ return /^git\s+status(\s+[^&;|]*)?$/.test(command.trim())
43
+ && !/(--porcelain|--short|-s\b|--long)/.test(command);
44
+ },
45
+ rewrite(command) {
46
+ return `${command.trim()} --porcelain=v1`;
47
+ },
48
+ },
49
+ {
50
+ name: 'pytest-quiet',
51
+ // Bare `pytest`/`python -m pytest`, not already carrying its own
52
+ // verbosity flag. -q drops the per-test PASSED noise; failure output
53
+ // (the only detail an agent actually needs to act on) is unaffected.
54
+ test(command) {
55
+ return /^(python[3]?\s+-m\s+)?pytest(\s+[^&;|]*)?$/.test(command.trim())
56
+ && !/(-q\b|--quiet|-v\b|--verbose|-vv)/.test(command);
57
+ },
58
+ rewrite(command) {
59
+ return `${command.trim()} -q`;
60
+ },
61
+ },
62
+ ];
63
+
64
+ /**
65
+ * Returns a rewritten command string if a rule applies, or null if none do
66
+ * (including on any internal error - never throws, this is a pure
67
+ * best-effort optimization, not a correctness requirement).
68
+ */
69
+ export function compactBashCommand(command) {
70
+ if (typeof command !== 'string' || !command.trim()) return null;
71
+ try {
72
+ for (const rule of _RULES) {
73
+ if (rule.test(command)) {
74
+ // No "is the rewrite shorter" check here on purpose: the command
75
+ // STRING itself is expected to grow (`--porcelain=v1` adds
76
+ // characters to the input), what shrinks is the tool's future
77
+ // OUTPUT once it runs - the size that actually matters, and the
78
+ // one thing this synchronous, pre-execution hook can never
79
+ // observe. The real "never worse than raw" guarantee here is that
80
+ // every rule is a hand-picked, known-safe flag on a real command
81
+ // (see the module docstring) - not something to (mis)approximate
82
+ // by comparing command lengths, which was tried and removed after
83
+ // review: it measured an unrelated quantity and would have passed
84
+ // or failed by accident, never by relevance.
85
+ return rule.rewrite(command) || null;
86
+ }
87
+ }
88
+ } catch {
89
+ return null;
90
+ }
91
+ return null;
92
+ }
93
+
94
+ /**
95
+ * Entry point for the PreToolUse hook: given the hook's own tool_name and
96
+ * tool_input, returns an `updatedInput` object to merge into
97
+ * hookSpecificOutput, or null if no rewrite applies. Only handles Bash for
98
+ * now - Read/Edit/Write's own large-output problem needs offset/limit
99
+ * changes that risk silently truncating content the agent actually needs
100
+ * (worse than raw, not just no-better), so those are deliberately not
101
+ * covered here; see the module docstring's file-reads note.
102
+ */
103
+ export function updatedInputFor(toolName, toolInput) {
104
+ if (toolName !== 'Bash') return null;
105
+ const command = toolInput?.command;
106
+ const rewritten = compactBashCommand(command);
107
+ return rewritten ? { command: rewritten } : null;
108
+ }
@@ -1,6 +1,7 @@
1
1
  import { loadConfig } from './config.js';
2
2
  import { api } from './api.js';
3
3
  import { usageForCurrentTurn } from './hook-report.js';
4
+ import { updatedInputFor } from './command-compaction.js';
4
5
 
5
6
  // Claude Code PreToolUse hook entry point: `troxy pretooluse-hook` (wired in
6
7
  // bin/troxy.js, registered by patchClaudeCodeHooks in init.js). Fires before
@@ -56,7 +57,29 @@ export function buildDenyPayload(reason) {
56
57
  };
57
58
  }
58
59
 
59
- function allow() {
60
+ // Same reasoning as buildDenyPayload above: exported and pure so the exact
61
+ // contract shape is testable without triggering allow()'s process.exit.
62
+ export function buildUpdatedInputPayload(updatedInput) {
63
+ return {
64
+ hookSpecificOutput: {
65
+ hookEventName: 'PreToolUse',
66
+ updatedInput,
67
+ },
68
+ };
69
+ }
70
+
71
+ // updatedInput: Token Optimization Step 4 - a command-rewrite from
72
+ // command-compaction.js, independent of and unrelated to the policy
73
+ // decision below (see that module's own docstring). Emitted alongside a
74
+ // plain allow (no permissionDecision at all - `undefined`/absent means
75
+ // "the normal permission flow applies", same as today's exit-0-with-
76
+ // nothing-printed behavior when there's no rewrite either), never mixed
77
+ // with a deny: a call about to be blocked doesn't need its command
78
+ // rewritten first.
79
+ function allow(updatedInput) {
80
+ if (updatedInput) {
81
+ process.stdout.write(JSON.stringify(buildUpdatedInputPayload(updatedInput)));
82
+ }
60
83
  process.exit(0);
61
84
  }
62
85
 
@@ -75,8 +98,16 @@ export async function runPreToolUseHook() {
75
98
  return allow(); // can't parse our own input - never block on that
76
99
  }
77
100
 
101
+ // Independent of everything below: a pure, local, synchronous command
102
+ // rewrite (Token Optimization Step 4) that applies (or doesn't)
103
+ // regardless of whether the policy check below even runs - it needs no
104
+ // API key, no transcript, no network call, so it's computed once, up
105
+ // front, and threaded through every allow() below rather than gated
106
+ // behind the same fail-open checks the policy path needs.
107
+ const rewrite = updatedInputFor(payload?.tool_name, payload?.tool_input);
108
+
78
109
  const transcriptPath = payload?.transcript_path;
79
- if (!transcriptPath) return allow();
110
+ if (!transcriptPath) return allow(rewrite);
80
111
 
81
112
  // transcript_path can lag the current turn by a beat (Claude Code's own
82
113
  // docs note this), so this may occasionally read the previous turn's
@@ -85,11 +116,11 @@ export async function runPreToolUseHook() {
85
116
  // not a wrong-direction failure (never wrongly denies because of it),
86
117
  // just an occasional miss, consistent with fail-open throughout this file.
87
118
  const usage = usageForCurrentTurn(transcriptPath);
88
- if (!usage || !usage.model) return allow(); // nothing to gate against yet
119
+ if (!usage || !usage.model) return allow(rewrite); // nothing to gate against yet
89
120
 
90
121
  const config = loadConfig();
91
122
  const apiKey = process.env.TROXY_API_KEY || config?.apiKey;
92
- if (!apiKey) return allow();
123
+ if (!apiKey) return allow(rewrite);
93
124
 
94
125
  const body = { model: usage.model, task: payload.tool_name || '' };
95
126
  // Real effort straight off the transcript entry (see hook-report.js's
@@ -120,7 +151,7 @@ export async function runPreToolUseHook() {
120
151
  // nowhere here to pause and wait on an approval), so it fails open
121
152
  // rather than hanging Claude Code - same reasoning as every other
122
153
  // "never block on our own uncertainty" branch in this file.
123
- return allow();
154
+ return allow(rewrite);
124
155
  } catch {
125
156
  // Network error, timeout, malformed response - never block the user's
126
157
  // session over our own failure.
@@ -0,0 +1,106 @@
1
+ // Pure logic only - no hook plumbing here, see pretooluse-hook.test.js for
2
+ // how updatedInputFor's result actually reaches Claude Code's own
3
+ // hookSpecificOutput contract.
4
+
5
+ import { describe, it } from 'node:test';
6
+ import assert from 'node:assert/strict';
7
+
8
+ import { compactBashCommand, updatedInputFor } from '../command-compaction.js';
9
+
10
+ describe('compactBashCommand - git status', () => {
11
+ it('rewrites bare `git status` to porcelain form', () => {
12
+ assert.equal(compactBashCommand('git status'), 'git status --porcelain=v1');
13
+ });
14
+
15
+ it('rewrites `git status` with a pathspec', () => {
16
+ assert.equal(compactBashCommand('git status src/'), 'git status src/ --porcelain=v1');
17
+ });
18
+
19
+ it('does not double-rewrite a command already using --porcelain', () => {
20
+ assert.equal(compactBashCommand('git status --porcelain'), null);
21
+ });
22
+
23
+ it('does not rewrite a command already using -s/--short', () => {
24
+ assert.equal(compactBashCommand('git status -s'), null);
25
+ assert.equal(compactBashCommand('git status --short'), null);
26
+ });
27
+
28
+ it('does not touch git status inside a compound command', () => {
29
+ // Deliberately conservative - see the module docstring on why a
30
+ // missed rewrite is fine but a wrong one isn't.
31
+ assert.equal(compactBashCommand('git status && git log'), null);
32
+ assert.equal(compactBashCommand('git status; echo done'), null);
33
+ assert.equal(compactBashCommand('git status | cat'), null);
34
+ });
35
+
36
+ it('does not touch an unrelated command containing the word status', () => {
37
+ assert.equal(compactBashCommand('echo "git status is useful"'), null);
38
+ });
39
+ });
40
+
41
+ describe('compactBashCommand - pytest', () => {
42
+ it('rewrites bare `pytest` to add -q', () => {
43
+ assert.equal(compactBashCommand('pytest'), 'pytest -q');
44
+ });
45
+
46
+ it('rewrites `pytest` with a path argument', () => {
47
+ assert.equal(compactBashCommand('pytest tests/'), 'pytest tests/ -q');
48
+ });
49
+
50
+ it('rewrites `python -m pytest`', () => {
51
+ assert.equal(compactBashCommand('python -m pytest'), 'python -m pytest -q');
52
+ assert.equal(compactBashCommand('python3 -m pytest'), 'python3 -m pytest -q');
53
+ });
54
+
55
+ it('does not double-rewrite a command already carrying a verbosity flag', () => {
56
+ assert.equal(compactBashCommand('pytest -q'), null);
57
+ assert.equal(compactBashCommand('pytest -v'), null);
58
+ assert.equal(compactBashCommand('pytest --verbose'), null);
59
+ assert.equal(compactBashCommand('pytest --quiet'), null);
60
+ });
61
+ });
62
+
63
+ describe('compactBashCommand - unmatched input', () => {
64
+ it('returns null for a command with no matching rule', () => {
65
+ assert.equal(compactBashCommand('ls -la'), null);
66
+ assert.equal(compactBashCommand('git diff'), null);
67
+ assert.equal(compactBashCommand('git log'), null);
68
+ });
69
+
70
+ it('never throws on malformed input', () => {
71
+ assert.doesNotThrow(() => compactBashCommand(null));
72
+ assert.doesNotThrow(() => compactBashCommand(undefined));
73
+ assert.doesNotThrow(() => compactBashCommand(42));
74
+ assert.doesNotThrow(() => compactBashCommand(''));
75
+ assert.doesNotThrow(() => compactBashCommand(' '));
76
+ });
77
+
78
+ it('returns null, not a throw, for non-string input', () => {
79
+ assert.equal(compactBashCommand(null), null);
80
+ assert.equal(compactBashCommand(undefined), null);
81
+ assert.equal(compactBashCommand(42), null);
82
+ });
83
+ });
84
+
85
+ describe('updatedInputFor', () => {
86
+ it('returns an updatedInput object for a rewritable Bash command', () => {
87
+ assert.deepEqual(updatedInputFor('Bash', { command: 'git status' }), {
88
+ command: 'git status --porcelain=v1',
89
+ });
90
+ });
91
+
92
+ it('returns null for a non-Bash tool', () => {
93
+ assert.equal(updatedInputFor('Read', { file_path: '/tmp/x' }), null);
94
+ assert.equal(updatedInputFor('Edit', { command: 'git status' }), null);
95
+ });
96
+
97
+ it('returns null for a Bash command with no matching rule', () => {
98
+ assert.equal(updatedInputFor('Bash', { command: 'ls -la' }), null);
99
+ });
100
+
101
+ it('never throws on a malformed tool_input', () => {
102
+ assert.doesNotThrow(() => updatedInputFor('Bash', null));
103
+ assert.doesNotThrow(() => updatedInputFor('Bash', {}));
104
+ assert.doesNotThrow(() => updatedInputFor(null, null));
105
+ });
106
+ });
@@ -14,7 +14,8 @@
14
14
  import { describe, it } from 'node:test';
15
15
  import assert from 'node:assert/strict';
16
16
 
17
- import { buildDenyPayload } from '../pretooluse-hook.js';
17
+ import { buildDenyPayload, buildUpdatedInputPayload } from '../pretooluse-hook.js';
18
+ import { updatedInputFor } from '../command-compaction.js';
18
19
 
19
20
  describe('buildDenyPayload', () => {
20
21
  it('matches the exact PreToolUse hookSpecificOutput contract', () => {
@@ -40,3 +41,41 @@ describe('buildDenyPayload', () => {
40
41
  );
41
42
  });
42
43
  });
44
+
45
+ // Token Optimization Step 4: the command-rewrite path is independent of the
46
+ // policy-check path above (see pretooluse-hook.js's own comment on why
47
+ // updatedInput is computed once, up front, and threaded through every
48
+ // allow() branch) - these tests cover just the contract shape
49
+ // buildUpdatedInputPayload produces, same "pure and testable without
50
+ // process.exit" reasoning as buildDenyPayload's own tests above.
51
+ describe('buildUpdatedInputPayload', () => {
52
+ it('matches the exact PreToolUse hookSpecificOutput contract for updatedInput', () => {
53
+ const payload = buildUpdatedInputPayload({ command: 'git status --porcelain=v1' });
54
+ assert.deepEqual(payload, {
55
+ hookSpecificOutput: {
56
+ hookEventName: 'PreToolUse',
57
+ updatedInput: { command: 'git status --porcelain=v1' },
58
+ },
59
+ });
60
+ });
61
+
62
+ it('carries no permissionDecision field - a plain allow, not a decision', () => {
63
+ const payload = buildUpdatedInputPayload({ command: 'pytest -q' });
64
+ assert.equal(payload.hookSpecificOutput.permissionDecision, undefined);
65
+ });
66
+
67
+ it('is valid JSON when stringified', () => {
68
+ assert.doesNotThrow(() => JSON.stringify(buildUpdatedInputPayload({ command: 'git status --porcelain=v1' })));
69
+ });
70
+ });
71
+
72
+ describe('updatedInputFor wired to real hook payload shapes', () => {
73
+ it('produces a rewrite for a real Bash tool_use payload shape', () => {
74
+ const rewrite = updatedInputFor('Bash', { command: 'git status', description: 'Check status' });
75
+ assert.deepEqual(rewrite, { command: 'git status --porcelain=v1' });
76
+ });
77
+
78
+ it('produces nothing for a tool this hook does not rewrite', () => {
79
+ assert.equal(updatedInputFor('Read', { file_path: '/tmp/x.py' }), null);
80
+ });
81
+ });