[object Object]

← back to Cli Printing Press

fix(cli): create llm prompt temp files privately (#1674)

c5d86825c65118f377d3515e1ce0ef32750c447f · 2026-05-19 15:07:15 -0700 · Trevin Chow

Files touched

Diff

commit c5d86825c65118f377d3515e1ce0ef32750c447f
Author: Trevin Chow <trevin@trevinchow.com>
Date:   Tue May 19 15:07:15 2026 -0700

    fix(cli): create llm prompt temp files privately (#1674)
---
 internal/llm/llm.go      | 38 ++++++++++++++++++--------
 internal/llm/llm_test.go | 70 ++++++++++++++++++++++++++++++++++++++++++++++++
 2 files changed, 97 insertions(+), 11 deletions(-)

diff --git a/internal/llm/llm.go b/internal/llm/llm.go
index 8ee50201..8fb6ebde 100644
--- a/internal/llm/llm.go
+++ b/internal/llm/llm.go
@@ -4,9 +4,7 @@ import (
 	"fmt"
 	"os"
 	"os/exec"
-	"path/filepath"
 	"strings"
-	"time"
 )
 
 // Available returns true if any supported LLM CLI is installed.
@@ -17,16 +15,9 @@ func Available() bool {
 }
 
 // Run sends a prompt to the best available LLM CLI and returns the response.
-// It tries claude first, then falls back to codex. The prompt is written to a
-// temp file and passed via the -p flag to avoid ARG_MAX issues with large prompts.
+// It tries claude first, then falls back to codex. Long Claude prompts are
+// written to a temp file and passed by reference to avoid ARG_MAX issues.
 func Run(prompt string) (string, error) {
-	// Write prompt to temp file to avoid ARG_MAX issues
-	tmpFile := filepath.Join(os.TempDir(), fmt.Sprintf("llm-prompt-%d.md", time.Now().UnixNano()))
-	if err := os.WriteFile(tmpFile, []byte(prompt), 0644); err != nil {
-		return "", fmt.Errorf("writing prompt: %w", err)
-	}
-	defer func() { _ = os.Remove(tmpFile) }()
-
 	// Try claude first (-p / --print mode, prompt as positional arg)
 	if path, err := exec.LookPath("claude"); err == nil {
 		// For short prompts, pass directly. For long prompts, use a temp file referenced in the prompt.
@@ -34,6 +25,12 @@ func Run(prompt string) (string, error) {
 		if len(prompt) < 100000 {
 			cmd = exec.Command(path, "-p", prompt, "--output-format", "text")
 		} else {
+			tmpFile, cleanup, err := writePromptTempFile(prompt)
+			if err != nil {
+				return "", err
+			}
+			defer cleanup()
+
 			// Write to temp file and tell Claude to read it
 			metaPrompt := fmt.Sprintf("Read the file at %s and follow the instructions inside it exactly.", tmpFile)
 			cmd = exec.Command(path, "-p", metaPrompt, "--output-format", "text")
@@ -60,3 +57,22 @@ func Run(prompt string) (string, error) {
 
 	return "", fmt.Errorf("no LLM CLI found (install claude or codex)")
 }
+
+func writePromptTempFile(prompt string) (string, func(), error) {
+	promptFile, err := os.CreateTemp("", "llm-prompt-*.md")
+	if err != nil {
+		return "", nil, fmt.Errorf("creating prompt file: %w", err)
+	}
+	tmpFile := promptFile.Name()
+	if _, err := promptFile.WriteString(prompt); err != nil {
+		_ = promptFile.Close()
+		_ = os.Remove(tmpFile)
+		return "", nil, fmt.Errorf("writing prompt: %w", err)
+	}
+	if err := promptFile.Close(); err != nil {
+		_ = os.Remove(tmpFile)
+		return "", nil, fmt.Errorf("closing prompt file: %w", err)
+	}
+
+	return tmpFile, func() { _ = os.Remove(tmpFile) }, nil
+}
diff --git a/internal/llm/llm_test.go b/internal/llm/llm_test.go
index 398971fe..c9e545f3 100644
--- a/internal/llm/llm_test.go
+++ b/internal/llm/llm_test.go
@@ -1,10 +1,15 @@
 package llm
 
 import (
+	"os"
 	"os/exec"
+	"path/filepath"
+	"runtime"
+	"strings"
 	"testing"
 
 	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
 )
 
 func TestAvailable(t *testing.T) {
@@ -24,3 +29,68 @@ func TestRunReturnsErrorWhenNoLLM(t *testing.T) {
 	assert.Error(t, err)
 	assert.Contains(t, err.Error(), "no LLM CLI found")
 }
+
+func TestRunLongPromptUsesPrivateTempFile(t *testing.T) {
+	if runtime.GOOS == "windows" {
+		t.Skip("Unix file modes are not portable to Windows")
+	}
+
+	binDir := t.TempDir()
+	claudePath := filepath.Join(binDir, "claude")
+	script := `#!/bin/sh
+prompt="$2"
+path=${prompt#Read the file at }
+path=${path% and follow the instructions inside it exactly.}
+stat -c %a "$path" 2>/dev/null || stat -f "%OLp" "$path"
+`
+	require.NoError(t, os.WriteFile(claudePath, []byte(script), 0700))
+	t.Setenv("PATH", binDir+string(os.PathListSeparator)+os.Getenv("PATH"))
+
+	response, err := Run(strings.Repeat("x", 100001))
+	require.NoError(t, err)
+	assert.Equal(t, "600", response)
+}
+
+func TestRunShortPromptDoesNotCreateTempFile(t *testing.T) {
+	binDir := t.TempDir()
+	tmpDir := t.TempDir()
+	claudePath := filepath.Join(binDir, "claude")
+	script := `#!/bin/sh
+for f in "$TMPDIR"/llm-prompt-*.md; do
+	if [ -e "$f" ]; then
+		echo "unexpected temp file"
+		exit 1
+	fi
+done
+printf ok
+`
+	require.NoError(t, os.WriteFile(claudePath, []byte(script), 0700))
+	t.Setenv("PATH", binDir)
+	t.Setenv("TMPDIR", tmpDir)
+
+	response, err := Run("short prompt")
+	require.NoError(t, err)
+	assert.Equal(t, "ok", response)
+}
+
+func TestRunCodexFallbackDoesNotCreateTempFile(t *testing.T) {
+	binDir := t.TempDir()
+	tmpDir := t.TempDir()
+	codexPath := filepath.Join(binDir, "codex")
+	script := `#!/bin/sh
+for f in "$TMPDIR"/llm-prompt-*.md; do
+	if [ -e "$f" ]; then
+		echo "unexpected temp file"
+		exit 1
+	fi
+done
+printf ok
+`
+	require.NoError(t, os.WriteFile(codexPath, []byte(script), 0700))
+	t.Setenv("PATH", binDir)
+	t.Setenv("TMPDIR", tmpDir)
+
+	response, err := Run(strings.Repeat("x", 100001))
+	require.NoError(t, err)
+	assert.Equal(t, "ok", response)
+}

← 9a474335 fix(ci): trust queued Greptile gate  ·  back to Cli Printing Press  ·  fix(cli): verify-mode HTTP-verb short-circuit + N1 envelope 75e4d1b9 →