authorgravatar for maxtheyoung@gmail.comMaxwell Young <maxtheyoung@gmail.com> 2026-09-08 08:50:58+10:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-07 15:50:58-07:00
log95834d68aa475a8afe4b2b14eaedffb35b590dab
treea7120d5cbff9d716d8fb6d7273c890ba66aa1b3b
parentdadba6d95d2c4515956a6c7fef184378987e13b7
signature Signed by PGP key B5690EEEBB952194

fix(server): disable executable capabilities in Claude metadata generation (#4169)


2 files changed, 109 insertions(+), 30 deletions(-)

apps/server/src/textGeneration/ClaudeTextGeneration.test.ts+82-19
......@@ -41,13 +41,37 @@ function makeFakeClaudeBinary(dir: string) {
4141 name: "claude",
4242 platform,
4343 source: [
44 'const args = process.argv.slice(2).join(" ");',
44 "const argv = process.argv.slice(2);",
45 'const args = argv.join(" ");',
46 'const { realpathSync } = await import("node:fs");',
4547 "",
4648 "function fail(message, code) {",
4749 ' process.stderr.write(message + "\\n");',
4850 " process.exit(code);",
4951 "}",
5052 "",
53 'const toolsIndex = argv.indexOf("--tools");',
54 'if (toolsIndex === -1 || argv[toolsIndex + 1] !== "") {',
55 ' fail("text generation must receive an explicit empty tool set", 6);',
56 "}",
57 'if (argv.includes("--dangerously-skip-permissions")) {',
58 ' fail("text generation must not bypass permissions", 7);',
59 "}",
60 'if (!argv.includes("--disable-slash-commands")) {',
61 ' fail("text generation must disable skills", 8);',
62 "}",
63 'if (!argv.includes("--strict-mcp-config")) {',
64 ' fail("text generation must not load configured MCP servers", 9);',
65 "}",
66 'const settingsIndex = argv.indexOf("--settings");',
67 "if (settingsIndex === -1 || JSON.parse(argv[settingsIndex + 1]).disableAllHooks !== true) {",
68 ' fail("text generation must disable hooks", 10);',
69 "}",
70 "const cwdMustNotBe = process.env.T3_FAKE_CLAUDE_CWD_MUST_NOT_BE;",
71 "if (cwdMustNotBe && realpathSync(process.cwd()) === realpathSync(cwdMustNotBe)) {",
72 ' fail("text generation ran in the project directory", 11);',
73 "}",
74 "",
5175 'let stdinContent = "";',
5276 "if (!process.stdin.isTTY) {",
5377 " const chunks = [];",
......@@ -100,6 +124,7 @@ function withFakeClaudeEnv<A, E, R>(
100124 argsMustNotContain?: string;
101125 stdinMustContain?: string;
102126 configDirMustBe?: string;
127 cwdMustNotBe?: string;
103128 claudeConfig?: Partial<ClaudeSettings>;
104129 },
105130 effectFn: (textGeneration: TextGeneration.TextGeneration["Service"]) => Effect.Effect<A, E, R>,
......@@ -117,6 +142,7 @@ function withFakeClaudeEnv<A, E, R>(
117142 const previousArgsMustNotContain = process.env.T3_FAKE_CLAUDE_ARGS_MUST_NOT_CONTAIN;
118143 const previousStdinMustContain = process.env.T3_FAKE_CLAUDE_STDIN_MUST_CONTAIN;
119144 const previousConfigDirMustBe = process.env.T3_FAKE_CLAUDE_CONFIG_DIR_MUST_BE;
145 const previousCwdMustNotBe = process.env.T3_FAKE_CLAUDE_CWD_MUST_NOT_BE;
120146
121147 yield* Effect.acquireRelease(
122148 Effect.sync(() => {
......@@ -153,6 +179,12 @@ function withFakeClaudeEnv<A, E, R>(
153179 delete process.env.T3_FAKE_CLAUDE_STDIN_MUST_CONTAIN;
154180 }
155181
182 if (input.cwdMustNotBe !== undefined) {
183 process.env.T3_FAKE_CLAUDE_CWD_MUST_NOT_BE = input.cwdMustNotBe;
184 } else {
185 delete process.env.T3_FAKE_CLAUDE_CWD_MUST_NOT_BE;
186 }
187
156188 if (input.configDirMustBe !== undefined) {
157189 process.env.T3_FAKE_CLAUDE_CONFIG_DIR_MUST_BE = input.configDirMustBe;
158190 } else {
......@@ -199,6 +231,12 @@ function withFakeClaudeEnv<A, E, R>(
199231 process.env.T3_FAKE_CLAUDE_STDIN_MUST_CONTAIN = previousStdinMustContain;
200232 }
201233
234 if (previousCwdMustNotBe === undefined) {
235 delete process.env.T3_FAKE_CLAUDE_CWD_MUST_NOT_BE;
236 } else {
237 process.env.T3_FAKE_CLAUDE_CWD_MUST_NOT_BE = previousCwdMustNotBe;
238 }
239
202240 if (previousConfigDirMustBe === undefined) {
203241 delete process.env.T3_FAKE_CLAUDE_CONFIG_DIR_MUST_BE;
204242 } else {
......@@ -227,7 +265,7 @@ it.layer(ClaudeTextGenerationTestLayer)("ClaudeTextGeneration", (it) => {
227265 body: "",
228266 },
229267 }),
230 argsMustContain: '--settings {"alwaysThinkingEnabled":false}',
268 argsMustContain: '--settings {"disableAllHooks":true,"alwaysThinkingEnabled":false}',
231269 argsMustNotContain: "--effort",
232270 },
233271 (textGeneration) =>
......@@ -263,7 +301,7 @@ it.layer(ClaudeTextGenerationTestLayer)("ClaudeTextGeneration", (it) => {
263301 body: "",
264302 },
265303 }),
266 argsMustContain: `--model ${SYNTHETIC_CLAUDE_COLLIDING_ALIAS} --dangerously-skip-permissions`,
304 argsMustContain: `--model ${SYNTHETIC_CLAUDE_COLLIDING_ALIAS} --settings`,
267305 claudeConfig: { customModels: [SYNTHETIC_CLAUDE_COLLIDING_ALIAS] },
268306 },
269307 (textGeneration) =>
......@@ -302,7 +340,7 @@ it.layer(ClaudeTextGenerationTestLayer)("ClaudeTextGeneration", (it) => {
302340 body: "Body",
303341 },
304342 }),
305 argsMustContain: `--model ${SYNTHETIC_CLAUDE_CAPABLE_MODEL}[expanded] --effort max --settings {"fastMode":true} --dangerously-skip-permissions`,
343 argsMustContain: `--model ${SYNTHETIC_CLAUDE_CAPABLE_MODEL}[expanded] --effort max --settings {"disableAllHooks":true,"fastMode":true}`,
306344 claudeConfig: { customModels: [SYNTHETIC_CLAUDE_COLLIDING_ALIAS] },
307345 },
308346 (textGeneration) =>
......@@ -331,33 +369,58 @@ it.layer(ClaudeTextGenerationTestLayer)("ClaudeTextGeneration", (it) => {
331369 ),
332370 );
333371
334 it.effect("generates thread titles through the Claude provider", () =>
372 it.effect(
373 "generates thread titles outside the project with tools, skills, and hooks disabled",
374 () =>
375 withFakeClaudeEnv(
376 {
377 output: JSON.stringify({
378 structured_output: {
379 title:
380 ' "Reconnect failures after restart because the session state does not recover" ',
381 },
382 }),
383 cwdMustNotBe: process.cwd(),
384 stdinMustContain: "/call-script",
385 },
386 (textGeneration) =>
387 Effect.gen(function* () {
388 const generated = yield* textGeneration.generateThreadTitle({
389 cwd: process.cwd(),
390 message: "/call-script",
391 modelSelection: {
392 instanceId: ProviderInstanceId.make("claudeAgent"),
393 model: SYNTHETIC_CLAUDE_STANDARD_MODEL,
394 },
395 });
396
397 expect(generated.title).toBe(
398 sanitizeThreadTitle(
399 '"Reconnect failures after restart because the session state does not recover"',
400 ),
401 );
402 }),
403 ),
404 );
405
406 it.effect("generates branch names from skill prompts without executable capabilities", () =>
335407 withFakeClaudeEnv(
336408 {
337 output: JSON.stringify({
338 structured_output: {
339 title:
340 ' "Reconnect failures after restart because the session state does not recover" ',
341 },
342 }),
343 stdinMustContain: "Please investigate reconnect failures after restarting the session.",
409 output: JSON.stringify({ structured_output: { branch: "call-script" } }),
410 stdinMustContain: "/call-script",
344411 },
345412 (textGeneration) =>
346413 Effect.gen(function* () {
347 const generated = yield* textGeneration.generateThreadTitle({
414 const generated = yield* textGeneration.generateBranchName({
348415 cwd: process.cwd(),
349 message: "Please investigate reconnect failures after restarting the session.",
416 message: "/call-script",
350417 modelSelection: {
351418 instanceId: ProviderInstanceId.make("claudeAgent"),
352419 model: SYNTHETIC_CLAUDE_STANDARD_MODEL,
353420 },
354421 });
355422
356 expect(generated.title).toBe(
357 sanitizeThreadTitle(
358 '"Reconnect failures after restart because the session state does not recover"',
359 ),
360 );
423 expect(generated.branch).toBe("call-script");
361424 }),
362425 ),
363426 );
apps/server/src/textGeneration/ClaudeTextGeneration.ts+27-11
......@@ -8,6 +8,7 @@
88 * @module ClaudeTextGeneration
99 */
1010import * as Effect from "effect/Effect";
11import * as FileSystem from "effect/FileSystem";
1112import * as Option from "effect/Option";
1213import * as Schema from "effect/Schema";
1314import * as Stream from "effect/Stream";
......@@ -75,6 +76,7 @@ export const makeClaudeTextGeneration = Effect.fn("makeClaudeTextGeneration")(fu
7576 modelCatalog: Effect.Effect<ClaudeModelCatalog> = Effect.succeed(BUNDLED_CLAUDE_MODEL_CATALOG),
7677) {
7778 const commandSpawner = yield* ChildProcessSpawner.ChildProcessSpawner;
79 const fileSystem = yield* FileSystem.FileSystem;
7880 const claudeEnvironment = yield* makeClaudeEnvironment(claudeSettings, environment);
7981 const scopedModelCatalog = modelCatalog.pipe(
8082 Effect.map((catalog) => scopeClaudeModelCatalog(catalog, claudeSettings.customModels)),
......@@ -171,20 +173,29 @@ export const makeClaudeTextGeneration = Effect.fn("makeClaudeTextGeneration")(fu
171173 const fastMode =
172174 fastModeDescriptor?.type === "boolean" ? fastModeDescriptor.currentValue : undefined;
173175 const settings = {
176 disableAllHooks: true,
174177 ...(typeof thinking === "boolean" ? { alwaysThinkingEnabled: thinking } : {}),
175178 ...(fastMode ? { fastMode: true } : {}),
176179 ...(ultracode ? { ultracode: true } : {}),
177180 };
178 const settingsJson =
179 Object.keys(settings).length > 0
180 ? yield* encodeJsonForOperation(
181 operation,
182 settings,
183 "Failed to encode Claude CLI settings.",
184 )
185 : undefined;
181 const settingsJson = yield* encodeJsonForOperation(
182 operation,
183 settings,
184 "Failed to encode Claude CLI settings.",
185 );
186186
187187 const runClaudeCommand = Effect.fn("runClaudeJson.runClaudeCommand")(function* () {
188 // Titles need only the supplied prompt, not configuration from the checkout.
189 const workingDirectory =
190 operation === "generateThreadTitle"
191 ? yield* fileSystem
192 .makeTempDirectoryScoped({ prefix: "t3code-claude-title-" })
193 .pipe(
194 Effect.mapError((cause) =>
195 normalizeCliError("claude", operation, cause, "Failed to create title directory"),
196 ),
197 )
198 : cwd;
188199 const spawnCommand = yield* resolveSpawnCommand(
189200 claudeSettings.binaryPath || "claude",
190201 [
......@@ -196,14 +207,19 @@ export const makeClaudeTextGeneration = Effect.fn("makeClaudeTextGeneration")(fu
196207 "--model",
197208 resolveClaudeCatalogApiModelId(catalog, resolvedModelSelection),
198209 ...(cliEffort ? ["--effort", cliEffort] : []),
199 ...(settingsJson ? ["--settings", settingsJson] : []),
200 "--dangerously-skip-permissions",
210 "--settings",
211 settingsJson,
212 // Metadata prompts need no executable capabilities, even when they contain a skill name.
213 "--tools",
214 "",
215 "--disable-slash-commands",
216 "--strict-mcp-config",
201217 ],
202218 { env: claudeEnvironment },
203219 );
204220 const command = ChildProcess.make(spawnCommand.command, spawnCommand.args, {
205221 env: claudeEnvironment,
206 cwd,
222 cwd: workingDirectory,
207223 shell: spawnCommand.shell,
208224 stdin: {
209225 stream: Stream.encodeText(Stream.make(prompt)),