Skip to content

Commit 6a9d332

Browse files
authored
feat(agent): compress Bash command output via RTK when available (#3096)
1 parent 6fca46a commit 6a9d332

5 files changed

Lines changed: 383 additions & 34 deletions

File tree

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
// Pure git command-line parsing, shared by the signed-commit guard (hooks.ts)
2+
// and the RTK rewrite (session/rtk.ts). Kept dependency-free so importers don't
3+
// drag in the hooks module's heavier import chain.
4+
5+
// git global options that consume the following token as their value, so the
6+
// subcommand detector must skip both (mirrors the sandbox `git` PATH shim).
7+
const GIT_VALUE_FLAGS = new Set([
8+
"-C",
9+
"-c",
10+
"--git-dir",
11+
"--work-tree",
12+
"--namespace",
13+
"--exec-path",
14+
]);
15+
16+
/**
17+
* Returns the git subcommand of a single shell segment (e.g. "status" for
18+
* `git -C repo status`), or null when the segment isn't a git invocation.
19+
* A leading path is stripped so `/usr/bin/git` is still recognised as git.
20+
*/
21+
export function gitSubcommand(segment: string): string | null {
22+
const tokens = segment.trim().split(/\s+/).filter(Boolean);
23+
if (tokens.length === 0) return null;
24+
// Strip a leading path so `/usr/bin/git` is still recognised as git.
25+
const head = tokens[0].split("/").pop();
26+
if (head !== "git") return null;
27+
28+
let skipNext = false;
29+
for (const tok of tokens.slice(1)) {
30+
if (skipNext) {
31+
skipNext = false;
32+
continue;
33+
}
34+
if (GIT_VALUE_FLAGS.has(tok)) {
35+
skipNext = true;
36+
continue;
37+
}
38+
if (tok.startsWith("-")) continue;
39+
return tok;
40+
}
41+
return null;
42+
}

packages/agent/src/adapters/claude/hooks.ts

Lines changed: 1 addition & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import type { Logger } from "../../utils/logger";
77
import { SIGNED_COMMIT_QUALIFIED_TOOL_NAME } from "../signed-commit-shared";
88
import { stripCatLineNumbers } from "./conversion/sdk-to-acp";
99
import type { TaskState } from "./conversion/task-state";
10+
import { gitSubcommand } from "./git-command";
1011
import {
1112
extractPostHogSubTool,
1213
isPostHogDestructiveSubTool,
@@ -284,40 +285,6 @@ export const createSubagentRewriteHook =
284285
};
285286
};
286287

287-
// git global options that consume the following token as their value, so the
288-
// subcommand detector must skip both (mirrors the sandbox `git` PATH shim).
289-
const GIT_VALUE_FLAGS = new Set([
290-
"-C",
291-
"-c",
292-
"--git-dir",
293-
"--work-tree",
294-
"--namespace",
295-
"--exec-path",
296-
]);
297-
298-
function gitSubcommand(segment: string): string | null {
299-
const tokens = segment.trim().split(/\s+/).filter(Boolean);
300-
if (tokens.length === 0) return null;
301-
// Strip a leading path so `/usr/bin/git` is still recognised as git.
302-
const head = tokens[0].split("/").pop();
303-
if (head !== "git") return null;
304-
305-
let skipNext = false;
306-
for (const tok of tokens.slice(1)) {
307-
if (skipNext) {
308-
skipNext = false;
309-
continue;
310-
}
311-
if (GIT_VALUE_FLAGS.has(tok)) {
312-
skipNext = true;
313-
continue;
314-
}
315-
if (tok.startsWith("-")) continue;
316-
return tok;
317-
}
318-
return null;
319-
}
320-
321288
/**
322289
* True when any top-level shell segment of `command` is a direct `git commit` /
323290
* `git push` invocation (allowing `git`-level global flags like `-C path` or

packages/agent/src/adapters/claude/session/options.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ import type { EffortLevel } from "../types";
2929
import { APPENDED_INSTRUCTIONS } from "./instructions";
3030
import { loadUserClaudeJsonMcpServers } from "./mcp-config";
3131
import { DEFAULT_MODEL, FALLBACK_MODEL } from "./models";
32+
import { createRtkRewriteHook, resolveRtkPrefix } from "./rtk";
3233
import type { SettingsManager } from "./settings";
3334

3435
export interface ProcessSpawnedInfo {
@@ -210,6 +211,7 @@ function buildHooks(
210211
onEnsureLocalToolsConnected: (() => Promise<boolean>) | undefined,
211212
taskState: TaskState,
212213
onTaskStateChange: (() => Promise<void>) | undefined,
214+
rtkPrefix: string | undefined,
213215
): Options["hooks"] {
214216
const postToolUseHooks = [
215217
createPostToolUseHook({
@@ -232,6 +234,10 @@ function buildHooks(
232234
createSignedCommitGuardHook(logger, onEnsureLocalToolsConnected),
233235
);
234236
}
237+
// Registered last so the signed-commit guard evaluates the raw command first.
238+
if (rtkPrefix) {
239+
preToolUseHooks.push(createRtkRewriteHook(rtkPrefix, logger));
240+
}
235241

236242
const taskHook = createTaskHook(taskState, onTaskStateChange);
237243

@@ -457,6 +463,7 @@ export function buildSessionOptions(params: BuildOptionsParams): Options {
457463
params.onEnsureLocalToolsConnected,
458464
params.taskState,
459465
params.onTaskStateChange,
466+
resolveRtkPrefix(process.env),
460467
),
461468
outputFormat: params.outputFormat,
462469
abortController: getAbortController(
Lines changed: 180 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,180 @@
1+
import * as fs from "node:fs";
2+
import * as os from "node:os";
3+
import * as path from "node:path";
4+
import type { HookInput } from "@anthropic-ai/claude-agent-sdk";
5+
import { afterAll, beforeAll, describe, expect, test } from "vitest";
6+
import type { Logger } from "../../../utils/logger";
7+
import {
8+
createRtkRewriteHook,
9+
resolveRtkPrefix,
10+
rewriteBashForRtk,
11+
} from "./rtk";
12+
13+
describe("rewriteBashForRtk", () => {
14+
test.each([
15+
// Read-only git subcommands are wrapped.
16+
["git status", "rtk git status"],
17+
["git diff --stat", "rtk git diff --stat"],
18+
["git log --oneline -10", "rtk git log --oneline -10"],
19+
["git show HEAD", "rtk git show HEAD"],
20+
// Plain read-only commands are wrapped.
21+
["grep -rn foo src", "rtk grep -rn foo src"],
22+
["find . -name '*.ts'", "rtk find . -name '*.ts'"],
23+
["ls -la", "rtk ls -la"],
24+
])("wraps %j", (input, expected) => {
25+
expect(rewriteBashForRtk(input, "rtk")).toBe(expected);
26+
});
27+
28+
test.each([
29+
// Side-effecting git subcommands are left alone (also protects the
30+
// cloud signed-commit guard, which keys on a leading `git`).
31+
["git commit -m wip"],
32+
["git push origin main"],
33+
["git checkout -b feature"],
34+
// Commands RTK isn't wrapping in this cut.
35+
["npm test"],
36+
["cat file.ts"],
37+
["echo hello"],
38+
// Shell operators mean more than one invocation — never rewrite.
39+
["git status | grep foo"],
40+
["git status && ls"],
41+
["grep foo src > out.txt"],
42+
["ls; pwd"],
43+
["echo $(git status)"],
44+
// A leading env assignment or explicit path is not a bare allowlisted head.
45+
["FOO=bar git status"],
46+
["/usr/bin/git status"],
47+
// Empty / whitespace.
48+
[""],
49+
[" "],
50+
])("leaves %j unchanged", (input) => {
51+
expect(rewriteBashForRtk(input, "rtk")).toBeNull();
52+
});
53+
54+
test("is idempotent — does not double-wrap", () => {
55+
expect(rewriteBashForRtk("rtk git status", "rtk")).toBeNull();
56+
});
57+
58+
test("shell-quotes a binary path containing spaces", () => {
59+
expect(rewriteBashForRtk("git status", "/Apps/PostHog Code/rtk")).toBe(
60+
"'/Apps/PostHog Code/rtk' git status",
61+
);
62+
});
63+
64+
test("is idempotent for a space-containing prefix (quoted round-trip)", () => {
65+
const prefix = "/Apps/PostHog Code/rtk";
66+
const wrapped = rewriteBashForRtk("git status", prefix);
67+
expect(wrapped).toBe("'/Apps/PostHog Code/rtk' git status");
68+
// Feeding our own quoted output back through must not double-wrap, even
69+
// though the quoted first token never equals the bare prefix.
70+
expect(rewriteBashForRtk(wrapped as string, prefix)).toBeNull();
71+
});
72+
});
73+
74+
describe("resolveRtkPrefix", () => {
75+
let dir: string;
76+
let binary: string;
77+
78+
beforeAll(() => {
79+
dir = fs.mkdtempSync(path.join(os.tmpdir(), "rtk-test-"));
80+
binary = path.join(dir, "rtk");
81+
fs.writeFileSync(binary, "#!/bin/sh\n");
82+
});
83+
84+
afterAll(() => {
85+
fs.rmSync(dir, { recursive: true, force: true });
86+
});
87+
88+
test.each([
89+
["unset", undefined],
90+
["empty", ""],
91+
["1", "1"],
92+
["true", "true"],
93+
])("auto-detects rtk on PATH when POSTHOG_RTK is %s", (_label, value) => {
94+
expect(resolveRtkPrefix({ POSTHOG_RTK: value, PATH: dir })).toBe(binary);
95+
});
96+
97+
test("returns undefined when rtk is not on PATH", () => {
98+
expect(resolveRtkPrefix({ PATH: "/nonexistent" })).toBeUndefined();
99+
});
100+
101+
test.each([
102+
["zero", "0"],
103+
["false", "false"],
104+
["FALSE", "FALSE"],
105+
])(
106+
"opts out when POSTHOG_RTK is %s, even with rtk on PATH",
107+
(_label, value) => {
108+
expect(
109+
resolveRtkPrefix({ POSTHOG_RTK: value, PATH: dir }),
110+
).toBeUndefined();
111+
},
112+
);
113+
114+
test("uses an explicit path that exists", () => {
115+
expect(resolveRtkPrefix({ POSTHOG_RTK: binary })).toBe(binary);
116+
});
117+
118+
test("is disabled for an explicit path that does not exist", () => {
119+
expect(
120+
resolveRtkPrefix({ POSTHOG_RTK: path.join(dir, "missing") }),
121+
).toBeUndefined();
122+
});
123+
});
124+
125+
describe("createRtkRewriteHook", () => {
126+
const logger = {
127+
info() {},
128+
warn() {},
129+
error() {},
130+
debug() {},
131+
} as unknown as Logger;
132+
133+
const bashInput = (command: string): HookInput =>
134+
({
135+
session_id: "s",
136+
transcript_path: "/tmp/t",
137+
cwd: "/tmp",
138+
hook_event_name: "PreToolUse",
139+
tool_name: "Bash",
140+
tool_input: { command },
141+
}) as unknown as HookInput;
142+
143+
test("rewrites an eligible Bash command to updatedInput", async () => {
144+
const hook = createRtkRewriteHook("rtk", logger);
145+
const result = await hook(bashInput("git status"), "tool-1", {
146+
signal: new AbortController().signal,
147+
});
148+
expect(result).toMatchObject({
149+
continue: true,
150+
hookSpecificOutput: {
151+
hookEventName: "PreToolUse",
152+
updatedInput: { command: "rtk git status" },
153+
},
154+
});
155+
});
156+
157+
test("passes ineligible commands through untouched", async () => {
158+
const hook = createRtkRewriteHook("rtk", logger);
159+
const result = await hook(bashInput("npm test"), "tool-1", {
160+
signal: new AbortController().signal,
161+
});
162+
expect(result).toEqual({ continue: true });
163+
});
164+
165+
test("ignores non-Bash tools", async () => {
166+
const hook = createRtkRewriteHook("rtk", logger);
167+
const input = {
168+
session_id: "s",
169+
transcript_path: "/tmp/t",
170+
cwd: "/tmp",
171+
hook_event_name: "PreToolUse",
172+
tool_name: "Read",
173+
tool_input: { file_path: "/x" },
174+
} as unknown as HookInput;
175+
const result = await hook(input, "tool-1", {
176+
signal: new AbortController().signal,
177+
});
178+
expect(result).toEqual({ continue: true });
179+
});
180+
});

0 commit comments

Comments
 (0)