fix(claude-cli): pass system prompt via stdin instead of CLI argument

The --system-prompt flag exposed agent instructions and tool definitions
in the process argument list (visible via ps to all users on the host)
and risked hitting OS ARG_MAX limits when many tools are registered.

System prompt content is now prepended to the stdin payload, separated
from the user message by a --- delimiter. This is consistent with how
the gemini-cli and codex-cli providers already handle all input.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Eric Jacksch 2026-03-19 18:02:02 -04:00
parent 1010821ea4
commit 82e688a614
3 changed files with 81 additions and 12 deletions

View file

@ -27,13 +27,9 @@ func NewClaudeCliProvider(workspace string) *ClaudeCliProvider {
func (p *ClaudeCliProvider) Chat(
ctx context.Context, messages []Message, tools []ToolDefinition, model string, options map[string]any,
) (*LLMResponse, error) {
systemPrompt := p.buildSystemPrompt(messages, tools)
prompt := p.messagesToPrompt(messages)
prompt := p.buildStdinPrompt(messages, tools)
args := []string{"-p", "--output-format", "json", "--dangerously-skip-permissions", "--no-chrome"}
if systemPrompt != "" {
args = append(args, "--system-prompt", systemPrompt)
}
if model != "" && model != "claude-code" {
args = append(args, "--model", model)
}
@ -72,14 +68,26 @@ func (p *ClaudeCliProvider) GetDefaultModel() string {
return "claude-code"
}
// messagesToPrompt converts messages to a CLI-compatible prompt string.
// buildStdinPrompt combines the system context and conversation into a single stdin payload.
// Passing system instructions via stdin avoids exposing them in the process argument list and
// sidesteps operating-system ARG_MAX limits when many tools are registered.
func (p *ClaudeCliProvider) buildStdinPrompt(messages []Message, tools []ToolDefinition) string {
system := p.buildSystemPrompt(messages, tools)
conversation := p.messagesToPrompt(messages)
if system == "" {
return conversation
}
return system + "\n\n---\n\n" + conversation
}
// messagesToPrompt converts non-system messages to a CLI-compatible prompt string.
func (p *ClaudeCliProvider) messagesToPrompt(messages []Message) string {
var parts []string
for _, msg := range messages {
switch msg.Role {
case "system":
// handled via --system-prompt flag
// included in system context block; see buildStdinPrompt
case "user":
parts = append(parts, "User: "+msg.Content)
case "assistant":

View file

@ -300,9 +300,27 @@ func TestChat_ContextCancellation(t *testing.T) {
}
}
func TestChat_PassesSystemPromptFlag(t *testing.T) {
argsFile := filepath.Join(t.TempDir(), "args.txt")
script := createArgCaptureCLI(t, argsFile)
func TestChat_SystemPromptInStdinNotArgs(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("mock CLI scripts not supported on Windows")
}
dir := t.TempDir()
argsFile := filepath.Join(dir, "args.txt")
stdinFile := filepath.Join(dir, "stdin.txt")
// Script captures both args and stdin content.
script := filepath.Join(dir, "claude")
scriptContent := fmt.Sprintf(`#!/bin/sh
echo "$@" > '%s'
cat - > '%s'
cat <<'EOFMOCK'
{"type":"result","result":"ok","session_id":"test"}
EOFMOCK
`, argsFile, stdinFile)
if err := os.WriteFile(script, []byte(scriptContent), 0o755); err != nil {
t.Fatal(err)
}
p := NewClaudeCliProvider(t.TempDir())
p.command = script
@ -320,8 +338,20 @@ func TestChat_PassesSystemPromptFlag(t *testing.T) {
t.Fatalf("failed to read args file: %v", err)
}
args := string(argsBytes)
if !strings.Contains(args, "--system-prompt") {
t.Errorf("CLI args missing --system-prompt, got: %s", args)
if strings.Contains(args, "--system-prompt") {
t.Errorf("CLI args must not contain --system-prompt, got: %s", args)
}
stdinBytes, err := os.ReadFile(stdinFile)
if err != nil {
t.Fatalf("failed to read stdin file: %v", err)
}
stdin := string(stdinBytes)
if !strings.Contains(stdin, "Be helpful.") {
t.Errorf("stdin must contain system prompt content, got: %s", stdin)
}
if !strings.Contains(stdin, "Hi") {
t.Errorf("stdin must contain user message, got: %s", stdin)
}
}
@ -657,6 +687,36 @@ func TestBuildSystemPrompt_ToolsOnlyNoSystem(t *testing.T) {
}
}
// --- buildStdinPrompt tests ---
func TestBuildStdinPrompt_NoSystem(t *testing.T) {
p := NewClaudeCliProvider("/workspace")
messages := []Message{{Role: "user", Content: "Hello"}}
got := p.buildStdinPrompt(messages, nil)
if got != "Hello" {
t.Errorf("buildStdinPrompt() without system = %q, want %q", got, "Hello")
}
}
func TestBuildStdinPrompt_WithSystem(t *testing.T) {
p := NewClaudeCliProvider("/workspace")
messages := []Message{
{Role: "system", Content: "Be concise."},
{Role: "user", Content: "Hello"},
}
got := p.buildStdinPrompt(messages, nil)
if !strings.Contains(got, "Be concise.") {
t.Error("buildStdinPrompt() must include system content")
}
if !strings.Contains(got, "Hello") {
t.Error("buildStdinPrompt() must include user content")
}
// System section must precede user section.
if strings.Index(got, "Be concise.") > strings.Index(got, "Hello") {
t.Error("buildStdinPrompt() system content must appear before user content")
}
}
// --- buildToolsPrompt tests ---
func TestBuildToolsPrompt_SkipsNonFunction(t *testing.T) {

View file

@ -261,6 +261,7 @@ func TestCreateProvider_GeminiCliWithModel(t *testing.T) {
func TestCreateProvider_GeminiCliDefaultWorkspace(t *testing.T) {
cfg := config.DefaultConfig()
cfg.Agents.Defaults.Workspace = "" // clear default so the "." fallback is exercised
cfg.ModelList = []config.ModelConfig{
{ModelName: "gemini-cli", Model: "gemini-cli/gemini-cli"},
}