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) {...@@ -41,13 +41,37 @@ function makeFakeClaudeBinary(dir: string) {
41 name: "claude",41 name: "claude",
42 platform,42 platform,
43 source: [43 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");',
45 "",47 "",
46 "function fail(message, code) {",48 "function fail(message, code) {",
47 ' process.stderr.write(message + "\\n");',49 ' process.stderr.write(message + "\\n");',
48 " process.exit(code);",50 " process.exit(code);",
49 "}",51 "}",
50 "",52 "",
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 "",
51 'let stdinContent = "";',75 'let stdinContent = "";',
52 "if (!process.stdin.isTTY) {",76 "if (!process.stdin.isTTY) {",
53 " const chunks = [];",77 " const chunks = [];",
...@@ -100,6 +124,7 @@ function withFakeClaudeEnv<A, E, R>(...@@ -100,6 +124,7 @@ function withFakeClaudeEnv<A, E, R>(
100 argsMustNotContain?: string;124 argsMustNotContain?: string;
101 stdinMustContain?: string;125 stdinMustContain?: string;
102 configDirMustBe?: string;126 configDirMustBe?: string;
127 cwdMustNotBe?: string;
103 claudeConfig?: Partial<ClaudeSettings>;128 claudeConfig?: Partial<ClaudeSettings>;
104 },129 },
105 effectFn: (textGeneration: TextGeneration.TextGeneration["Service"]) => Effect.Effect<A, E, R>,130 effectFn: (textGeneration: TextGeneration.TextGeneration["Service"]) => Effect.Effect<A, E, R>,
...@@ -117,6 +142,7 @@ function withFakeClaudeEnv<A, E, R>(...@@ -117,6 +142,7 @@ function withFakeClaudeEnv<A, E, R>(
117 const previousArgsMustNotContain = process.env.T3_FAKE_CLAUDE_ARGS_MUST_NOT_CONTAIN;142 const previousArgsMustNotContain = process.env.T3_FAKE_CLAUDE_ARGS_MUST_NOT_CONTAIN;
118 const previousStdinMustContain = process.env.T3_FAKE_CLAUDE_STDIN_MUST_CONTAIN;143 const previousStdinMustContain = process.env.T3_FAKE_CLAUDE_STDIN_MUST_CONTAIN;
119 const previousConfigDirMustBe = process.env.T3_FAKE_CLAUDE_CONFIG_DIR_MUST_BE;144 const previousConfigDirMustBe = process.env.T3_FAKE_CLAUDE_CONFIG_DIR_MUST_BE;
145 const previousCwdMustNotBe = process.env.T3_FAKE_CLAUDE_CWD_MUST_NOT_BE;
120146
121 yield* Effect.acquireRelease(147 yield* Effect.acquireRelease(
122 Effect.sync(() => {148 Effect.sync(() => {
...@@ -153,6 +179,12 @@ function withFakeClaudeEnv<A, E, R>(...@@ -153,6 +179,12 @@ function withFakeClaudeEnv<A, E, R>(
153 delete process.env.T3_FAKE_CLAUDE_STDIN_MUST_CONTAIN;179 delete process.env.T3_FAKE_CLAUDE_STDIN_MUST_CONTAIN;
154 }180 }
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
156 if (input.configDirMustBe !== undefined) {188 if (input.configDirMustBe !== undefined) {
157 process.env.T3_FAKE_CLAUDE_CONFIG_DIR_MUST_BE = input.configDirMustBe;189 process.env.T3_FAKE_CLAUDE_CONFIG_DIR_MUST_BE = input.configDirMustBe;
158 } else {190 } else {
...@@ -199,6 +231,12 @@ function withFakeClaudeEnv<A, E, R>(...@@ -199,6 +231,12 @@ function withFakeClaudeEnv<A, E, R>(
199 process.env.T3_FAKE_CLAUDE_STDIN_MUST_CONTAIN = previousStdinMustContain;231 process.env.T3_FAKE_CLAUDE_STDIN_MUST_CONTAIN = previousStdinMustContain;
200 }232 }
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
202 if (previousConfigDirMustBe === undefined) {240 if (previousConfigDirMustBe === undefined) {
203 delete process.env.T3_FAKE_CLAUDE_CONFIG_DIR_MUST_BE;241 delete process.env.T3_FAKE_CLAUDE_CONFIG_DIR_MUST_BE;
204 } else {242 } else {
...@@ -227,7 +265,7 @@ it.layer(ClaudeTextGenerationTestLayer)("ClaudeTextGeneration", (it) => {...@@ -227,7 +265,7 @@ it.layer(ClaudeTextGenerationTestLayer)("ClaudeTextGeneration", (it) => {
227 body: "",265 body: "",
228 },266 },
229 }),267 }),
230 argsMustContain: '--settings {"alwaysThinkingEnabled":false}',268 argsMustContain: '--settings {"disableAllHooks":true,"alwaysThinkingEnabled":false}',
231 argsMustNotContain: "--effort",269 argsMustNotContain: "--effort",
232 },270 },
233 (textGeneration) =>271 (textGeneration) =>
...@@ -263,7 +301,7 @@ it.layer(ClaudeTextGenerationTestLayer)("ClaudeTextGeneration", (it) => {...@@ -263,7 +301,7 @@ it.layer(ClaudeTextGenerationTestLayer)("ClaudeTextGeneration", (it) => {
263 body: "",301 body: "",
264 },302 },
265 }),303 }),
266 argsMustContain: `--model ${SYNTHETIC_CLAUDE_COLLIDING_ALIAS} --dangerously-skip-permissions`,304 argsMustContain: `--model ${SYNTHETIC_CLAUDE_COLLIDING_ALIAS} --settings`,
267 claudeConfig: { customModels: [SYNTHETIC_CLAUDE_COLLIDING_ALIAS] },305 claudeConfig: { customModels: [SYNTHETIC_CLAUDE_COLLIDING_ALIAS] },
268 },306 },
269 (textGeneration) =>307 (textGeneration) =>
...@@ -302,7 +340,7 @@ it.layer(ClaudeTextGenerationTestLayer)("ClaudeTextGeneration", (it) => {...@@ -302,7 +340,7 @@ it.layer(ClaudeTextGenerationTestLayer)("ClaudeTextGeneration", (it) => {
302 body: "Body",340 body: "Body",
303 },341 },
304 }),342 }),
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}`,
306 claudeConfig: { customModels: [SYNTHETIC_CLAUDE_COLLIDING_ALIAS] },344 claudeConfig: { customModels: [SYNTHETIC_CLAUDE_COLLIDING_ALIAS] },
307 },345 },
308 (textGeneration) =>346 (textGeneration) =>
...@@ -331,33 +369,58 @@ it.layer(ClaudeTextGenerationTestLayer)("ClaudeTextGeneration", (it) => {...@@ -331,33 +369,58 @@ it.layer(ClaudeTextGenerationTestLayer)("ClaudeTextGeneration", (it) => {
331 ),369 ),
332 );370 );
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", () =>
335 withFakeClaudeEnv(407 withFakeClaudeEnv(
336 {408 {
337 output: JSON.stringify({409 output: JSON.stringify({ structured_output: { branch: "call-script" } }),
338 structured_output: {410 stdinMustContain: "/call-script",
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.",
344 },411 },
345 (textGeneration) =>412 (textGeneration) =>
346 Effect.gen(function* () {413 Effect.gen(function* () {
347 const generated = yield* textGeneration.generateThreadTitle({414 const generated = yield* textGeneration.generateBranchName({
348 cwd: process.cwd(),415 cwd: process.cwd(),
349 message: "Please investigate reconnect failures after restarting the session.",416 message: "/call-script",
350 modelSelection: {417 modelSelection: {
351 instanceId: ProviderInstanceId.make("claudeAgent"),418 instanceId: ProviderInstanceId.make("claudeAgent"),
352 model: SYNTHETIC_CLAUDE_STANDARD_MODEL,419 model: SYNTHETIC_CLAUDE_STANDARD_MODEL,
353 },420 },
354 });421 });
355422
356 expect(generated.title).toBe(423 expect(generated.branch).toBe("call-script");
357 sanitizeThreadTitle(
358 '"Reconnect failures after restart because the session state does not recover"',
359 ),
360 );
361 }),424 }),
362 ),425 ),
363 );426 );
apps/server/src/textGeneration/ClaudeTextGeneration.ts+27-11
...@@ -8,6 +8,7 @@...@@ -8,6 +8,7 @@
8 * @module ClaudeTextGeneration8 * @module ClaudeTextGeneration
9 */9 */
10import * as Effect from "effect/Effect";10import * as Effect from "effect/Effect";
11import * as FileSystem from "effect/FileSystem";
11import * as Option from "effect/Option";12import * as Option from "effect/Option";
12import * as Schema from "effect/Schema";13import * as Schema from "effect/Schema";
13import * as Stream from "effect/Stream";14import * as Stream from "effect/Stream";
...@@ -75,6 +76,7 @@ export const makeClaudeTextGeneration = Effect.fn("makeClaudeTextGeneration")(fu...@@ -75,6 +76,7 @@ export const makeClaudeTextGeneration = Effect.fn("makeClaudeTextGeneration")(fu
75 modelCatalog: Effect.Effect<ClaudeModelCatalog> = Effect.succeed(BUNDLED_CLAUDE_MODEL_CATALOG),76 modelCatalog: Effect.Effect<ClaudeModelCatalog> = Effect.succeed(BUNDLED_CLAUDE_MODEL_CATALOG),
76) {77) {
77 const commandSpawner = yield* ChildProcessSpawner.ChildProcessSpawner;78 const commandSpawner = yield* ChildProcessSpawner.ChildProcessSpawner;
79 const fileSystem = yield* FileSystem.FileSystem;
78 const claudeEnvironment = yield* makeClaudeEnvironment(claudeSettings, environment);80 const claudeEnvironment = yield* makeClaudeEnvironment(claudeSettings, environment);
79 const scopedModelCatalog = modelCatalog.pipe(81 const scopedModelCatalog = modelCatalog.pipe(
80 Effect.map((catalog) => scopeClaudeModelCatalog(catalog, claudeSettings.customModels)),82 Effect.map((catalog) => scopeClaudeModelCatalog(catalog, claudeSettings.customModels)),
...@@ -171,20 +173,29 @@ export const makeClaudeTextGeneration = Effect.fn("makeClaudeTextGeneration")(fu...@@ -171,20 +173,29 @@ export const makeClaudeTextGeneration = Effect.fn("makeClaudeTextGeneration")(fu
171 const fastMode =173 const fastMode =
172 fastModeDescriptor?.type === "boolean" ? fastModeDescriptor.currentValue : undefined;174 fastModeDescriptor?.type === "boolean" ? fastModeDescriptor.currentValue : undefined;
173 const settings = {175 const settings = {
176 disableAllHooks: true,
174 ...(typeof thinking === "boolean" ? { alwaysThinkingEnabled: thinking } : {}),177 ...(typeof thinking === "boolean" ? { alwaysThinkingEnabled: thinking } : {}),
175 ...(fastMode ? { fastMode: true } : {}),178 ...(fastMode ? { fastMode: true } : {}),
176 ...(ultracode ? { ultracode: true } : {}),179 ...(ultracode ? { ultracode: true } : {}),
177 };180 };
178 const settingsJson =181 const settingsJson = yield* encodeJsonForOperation(
179 Object.keys(settings).length > 0182 operation,
180 ? yield* encodeJsonForOperation(183 settings,
181 operation,184 "Failed to encode Claude CLI settings.",
182 settings,185 );
183 "Failed to encode Claude CLI settings.",
184 )
185 : undefined;
186186
187 const runClaudeCommand = Effect.fn("runClaudeJson.runClaudeCommand")(function* () {187 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;
188 const spawnCommand = yield* resolveSpawnCommand(199 const spawnCommand = yield* resolveSpawnCommand(
189 claudeSettings.binaryPath || "claude",200 claudeSettings.binaryPath || "claude",
190 [201 [
...@@ -196,14 +207,19 @@ export const makeClaudeTextGeneration = Effect.fn("makeClaudeTextGeneration")(fu...@@ -196,14 +207,19 @@ export const makeClaudeTextGeneration = Effect.fn("makeClaudeTextGeneration")(fu
196 "--model",207 "--model",
197 resolveClaudeCatalogApiModelId(catalog, resolvedModelSelection),208 resolveClaudeCatalogApiModelId(catalog, resolvedModelSelection),
198 ...(cliEffort ? ["--effort", cliEffort] : []),209 ...(cliEffort ? ["--effort", cliEffort] : []),
199 ...(settingsJson ? ["--settings", settingsJson] : []),210 "--settings",
200 "--dangerously-skip-permissions",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",
201 ],217 ],
202 { env: claudeEnvironment },218 { env: claudeEnvironment },
203 );219 );
204 const command = ChildProcess.make(spawnCommand.command, spawnCommand.args, {220 const command = ChildProcess.make(spawnCommand.command, spawnCommand.args, {
205 env: claudeEnvironment,221 env: claudeEnvironment,
206 cwd,222 cwd: workingDirectory,
207 shell: spawnCommand.shell,223 shell: spawnCommand.shell,
208 stdin: {224 stdin: {
209 stream: Stream.encodeText(Stream.make(prompt)),225 stream: Stream.encodeText(Stream.make(prompt)),