troxy-cli 1.29.4 → 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.
|
|
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
|
+
}
|
package/src/interceptor.js
CHANGED
|
@@ -73,6 +73,21 @@ export function parseConnectTarget(url) {
|
|
|
73
73
|
// meaningless (or wrong) to forward on either outbound leg.
|
|
74
74
|
const _HOP_BY_HOP_HEADERS = new Set(['host', 'connection', 'content-length', 'transfer-encoding']);
|
|
75
75
|
|
|
76
|
+
/** 'desktop' | 'terminal' | null. Verified live 2026-09-11 against two real
|
|
77
|
+
* captured user-agents from this same machine: terminal sends
|
|
78
|
+
* "claude-cli/2.1.236 (external, cli)", desktop sends "claude-cli/2.1.260
|
|
79
|
+
* (external, claude-desktop, agent-sdk/0.3.260)" - the literal substring
|
|
80
|
+
* "claude-desktop" is the one stable, explicit signal found; everything
|
|
81
|
+
* else in the string (version numbers) will drift across releases. null
|
|
82
|
+
* (never a guess) when the client sends no recognizable claude-cli
|
|
83
|
+
* user-agent at all - some other tool on this port, or a future format
|
|
84
|
+
* change this hasn't been updated for. */
|
|
85
|
+
export function detectSurface(incomingHeaders) {
|
|
86
|
+
const ua = incomingHeaders['user-agent'] || '';
|
|
87
|
+
if (!/^claude-cli\//.test(ua)) return null;
|
|
88
|
+
return ua.includes('claude-desktop') ? 'desktop' : 'terminal';
|
|
89
|
+
}
|
|
90
|
+
|
|
76
91
|
/** Headers for the leg going to Troxy: the client's own Authorization (an
|
|
77
92
|
* Anthropic API key or OAuth token) is never sent to Troxy as-is - it
|
|
78
93
|
* rides the side channel app.py already knows how to consume
|
|
@@ -87,6 +102,8 @@ export function buildTroxyForwardHeaders(incomingHeaders, troxyKey) {
|
|
|
87
102
|
if (incomingHeaders.authorization) {
|
|
88
103
|
headers['x-troxy-upstream-authorization'] = incomingHeaders.authorization;
|
|
89
104
|
}
|
|
105
|
+
const surface = detectSurface(incomingHeaders);
|
|
106
|
+
if (surface) headers['x-troxy-surface'] = surface;
|
|
90
107
|
headers.authorization = `Bearer ${troxyKey}`;
|
|
91
108
|
return headers;
|
|
92
109
|
}
|
package/src/pretooluse-hook.js
CHANGED
|
@@ -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
|
-
|
|
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
|
+
});
|
|
@@ -21,6 +21,7 @@ import { format } from 'node:util';
|
|
|
21
21
|
|
|
22
22
|
import {
|
|
23
23
|
parseConnectTarget,
|
|
24
|
+
detectSurface,
|
|
24
25
|
buildTroxyForwardHeaders,
|
|
25
26
|
buildRelayHeaders,
|
|
26
27
|
createInterceptor,
|
|
@@ -41,7 +42,43 @@ describe('parseConnectTarget', () => {
|
|
|
41
42
|
});
|
|
42
43
|
});
|
|
43
44
|
|
|
45
|
+
describe('detectSurface', () => {
|
|
46
|
+
// Real user-agents captured live 2026-09-11 from this same machine's own
|
|
47
|
+
// terminal and desktop-app traffic through this interceptor.
|
|
48
|
+
it('recognizes a real desktop-app user-agent', () => {
|
|
49
|
+
assert.equal(
|
|
50
|
+
detectSurface({ 'user-agent': 'claude-cli/2.1.260 (external, claude-desktop, agent-sdk/0.3.260)' }),
|
|
51
|
+
'desktop',
|
|
52
|
+
);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('recognizes a real terminal user-agent', () => {
|
|
56
|
+
assert.equal(detectSurface({ 'user-agent': 'claude-cli/2.1.236 (external, cli)' }), 'terminal');
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('returns null when there is no user-agent at all', () => {
|
|
60
|
+
assert.equal(detectSurface({}), null);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('returns null for a user-agent that is not claude-cli - never a guess', () => {
|
|
64
|
+
assert.equal(detectSurface({ 'user-agent': 'curl/8.4.0' }), null);
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
|
|
44
68
|
describe('buildTroxyForwardHeaders', () => {
|
|
69
|
+
it('sets x-troxy-surface from a recognized user-agent', () => {
|
|
70
|
+
const headers = buildTroxyForwardHeaders(
|
|
71
|
+
{ 'user-agent': 'claude-cli/2.1.260 (external, claude-desktop, agent-sdk/0.3.260)' },
|
|
72
|
+
'txy-key',
|
|
73
|
+
);
|
|
74
|
+
assert.equal(headers['x-troxy-surface'], 'desktop');
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('omits x-troxy-surface entirely when the surface cannot be determined', () => {
|
|
78
|
+
const headers = buildTroxyForwardHeaders({ 'user-agent': 'curl/8.4.0' }, 'txy-key');
|
|
79
|
+
assert.equal(headers['x-troxy-surface'], undefined);
|
|
80
|
+
});
|
|
81
|
+
|
|
45
82
|
it('translates the client\'s own Authorization into X-Troxy-Upstream-Authorization, ' +
|
|
46
83
|
'and sets Authorization to the Troxy key instead', () => {
|
|
47
84
|
const headers = buildTroxyForwardHeaders(
|
|
@@ -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
|
+
});
|