Merge pull request #435 from samdickson22/sam/eng-1940-daemon-fails-to-parse-permission_denials-array-from-claude

sam/eng-1940 - fix: handle permission_denials as objects instead of strings in claudecode-go
This commit is contained in:
Dex
2025-08-13 23:23:26 -05:00
committed by GitHub
4 changed files with 242 additions and 32 deletions

View File

@@ -336,17 +336,18 @@ func (s *Session) parseStreamingJSON(stdout, stderr io.Reader) {
// Store result if this is the final message
if event.Type == "result" {
s.result = &Result{
Type: event.Type,
Subtype: event.Subtype,
CostUSD: event.CostUSD,
IsError: event.IsError,
DurationMS: event.DurationMS,
DurationAPI: event.DurationAPI,
NumTurns: event.NumTurns,
Result: event.Result,
SessionID: event.SessionID,
Usage: event.Usage,
Error: event.Error,
Type: event.Type,
Subtype: event.Subtype,
CostUSD: event.CostUSD,
IsError: event.IsError,
DurationMS: event.DurationMS,
DurationAPI: event.DurationAPI,
NumTurns: event.NumTurns,
Result: event.Result,
SessionID: event.SessionID,
Usage: event.Usage,
Error: event.Error,
PermissionDenials: event.PermissionDenials,
}
}

View File

@@ -492,3 +492,41 @@ func TestClaudeCodeSchemaCompatibility(t *testing.T) {
}
})
}
func TestStreamEventWithPermissionDenials(t *testing.T) {
// Simulate Claude API response with permission denials
jsonData := `{
"type": "result",
"subtype": "completion",
"session_id": "test-session",
"total_cost_usd": 0.05,
"is_error": false,
"result": "Successfully created PR #430",
"permission_denials": [{
"tool_name": "Bash",
"tool_use_id": "toolu_01M6qJZgpwjmzg14TBS5Mwhm",
"tool_input": {"command": "git fetch origin main"}
}]
}`
var event claudecode.StreamEvent
err := json.Unmarshal([]byte(jsonData), &event)
if err != nil {
t.Fatalf("Failed to unmarshal event with permission denials: %v", err)
}
// Verify permission denials were parsed
if event.PermissionDenials == nil || len(event.PermissionDenials.Denials) != 1 {
t.Error("Permission denials not properly parsed")
}
// Verify session is not marked as error despite denials
if event.IsError {
t.Error("Event marked as error when it should be successful with denials")
}
// Verify denial details
if event.PermissionDenials.Denials[0].ToolName != "Bash" {
t.Errorf("Expected tool name 'Bash', got %s", event.PermissionDenials.Denials[0].ToolName)
}
}

View File

@@ -80,15 +80,72 @@ type StreamEvent struct {
APIKeySource string `json:"apiKeySource,omitempty"`
// Result event fields (when type="result")
CostUSD float64 `json:"total_cost_usd,omitempty"`
IsError bool `json:"is_error,omitempty"`
DurationMS int `json:"duration_ms,omitempty"`
DurationAPI int `json:"duration_api_ms,omitempty"`
NumTurns int `json:"num_turns,omitempty"`
Result string `json:"result,omitempty"`
Usage *Usage `json:"usage,omitempty"`
Error string `json:"error,omitempty"`
PermissionDenials []string `json:"permission_denials,omitempty"`
CostUSD float64 `json:"total_cost_usd,omitempty"`
IsError bool `json:"is_error,omitempty"`
DurationMS int `json:"duration_ms,omitempty"`
DurationAPI int `json:"duration_api_ms,omitempty"`
NumTurns int `json:"num_turns,omitempty"`
Result string `json:"result,omitempty"`
Usage *Usage `json:"usage,omitempty"`
Error string `json:"error,omitempty"`
PermissionDenials *PermissionDenials `json:"permission_denials,omitempty"`
}
// PermissionDenial represents a single permission denial from Claude
type PermissionDenial struct {
ToolName string `json:"tool_name"`
ToolUseID string `json:"tool_use_id"`
ToolInput map[string]interface{} `json:"tool_input,omitempty"`
}
// PermissionDenials handles flexible permission denial formats
type PermissionDenials struct {
Denials []PermissionDenial
}
// UnmarshalJSON implements custom unmarshaling to handle both object and string array formats
func (p *PermissionDenials) UnmarshalJSON(data []byte) error {
// Handle null
if string(data) == "null" {
p.Denials = nil
return nil
}
// Try as array of objects first (current API format)
var denials []PermissionDenial
if err := json.Unmarshal(data, &denials); err == nil {
p.Denials = denials
return nil
}
// Fall back to array of strings (legacy/simple format)
var legacyStrings []string
if err := json.Unmarshal(data, &legacyStrings); err == nil {
p.Denials = make([]PermissionDenial, len(legacyStrings))
for i, s := range legacyStrings {
p.Denials[i] = PermissionDenial{ToolName: s}
}
return nil
}
return fmt.Errorf("permission_denials is neither object array nor string array format")
}
// MarshalJSON implements custom marshaling
func (p PermissionDenials) MarshalJSON() ([]byte, error) {
return json.Marshal(p.Denials)
}
// ToStrings converts denials to string array for backward compatibility
func (p PermissionDenials) ToStrings() []string {
if p.Denials == nil {
return nil
}
result := make([]string, len(p.Denials))
for i, d := range p.Denials {
result[i] = d.ToolName
}
return result
}
// MCPStatus represents the status of an MCP server
@@ -175,18 +232,18 @@ type Usage struct {
// Result represents the final result of a Claude session
type Result struct {
Type string `json:"type"`
Subtype string `json:"subtype"`
CostUSD float64 `json:"total_cost_usd"`
IsError bool `json:"is_error"`
DurationMS int `json:"duration_ms"`
DurationAPI int `json:"duration_api_ms"`
NumTurns int `json:"num_turns"`
Result string `json:"result"`
SessionID string `json:"session_id"`
Usage *Usage `json:"usage,omitempty"`
Error string `json:"error,omitempty"`
PermissionDenials []string `json:"permission_denials,omitempty"`
Type string `json:"type"`
Subtype string `json:"subtype"`
CostUSD float64 `json:"total_cost_usd"`
IsError bool `json:"is_error"`
DurationMS int `json:"duration_ms"`
DurationAPI int `json:"duration_api_ms"`
NumTurns int `json:"num_turns"`
Result string `json:"result"`
SessionID string `json:"session_id"`
Usage *Usage `json:"usage,omitempty"`
Error string `json:"error,omitempty"`
PermissionDenials *PermissionDenials `json:"permission_denials,omitempty"`
}
// Session represents an active Claude session

View File

@@ -207,3 +207,117 @@ func TestContentStructWithContentField(t *testing.T) {
})
}
}
func TestPermissionDenialsUnmarshal(t *testing.T) {
tests := []struct {
name string
input string
expectDenials []PermissionDenial
expectError bool
}{
{
name: "object array format",
input: `[{
"tool_name": "Bash",
"tool_use_id": "toolu_01M6qJZgpwjmzg14TBS5Mwhm",
"tool_input": {"command": "git fetch origin main"}
}]`,
expectDenials: []PermissionDenial{{
ToolName: "Bash",
ToolUseID: "toolu_01M6qJZgpwjmzg14TBS5Mwhm",
ToolInput: map[string]interface{}{"command": "git fetch origin main"},
}},
expectError: false,
},
{
name: "multiple denials",
input: `[
{"tool_name": "Bash", "tool_use_id": "tool_1", "tool_input": {"command": "rm -rf /"}},
{"tool_name": "Write", "tool_use_id": "tool_2", "tool_input": {"path": "/etc/passwd"}}
]`,
expectDenials: []PermissionDenial{
{ToolName: "Bash", ToolUseID: "tool_1", ToolInput: map[string]interface{}{"command": "rm -rf /"}},
{ToolName: "Write", ToolUseID: "tool_2", ToolInput: map[string]interface{}{"path": "/etc/passwd"}},
},
expectError: false,
},
{
name: "legacy string array format",
input: `["Bash", "Write", "Delete"]`,
expectDenials: []PermissionDenial{{ToolName: "Bash"}, {ToolName: "Write"}, {ToolName: "Delete"}},
expectError: false,
},
{
name: "empty array",
input: `[]`,
expectDenials: []PermissionDenial{},
expectError: false,
},
{
name: "null value",
input: `null`,
expectDenials: nil,
expectError: false,
},
{
name: "invalid format - plain object",
input: `{"tool_name": "Bash"}`,
expectError: true,
},
{
name: "invalid format - number",
input: `123`,
expectError: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var p PermissionDenials
err := json.Unmarshal([]byte(tt.input), &p)
if (err != nil) != tt.expectError {
t.Errorf("PermissionDenials.UnmarshalJSON() error = %v, expectError %v", err, tt.expectError)
return
}
if !tt.expectError {
if len(p.Denials) != len(tt.expectDenials) {
t.Errorf("PermissionDenials.UnmarshalJSON() got %d denials, want %d", len(p.Denials), len(tt.expectDenials))
return
}
for i, denial := range p.Denials {
if denial.ToolName != tt.expectDenials[i].ToolName {
t.Errorf("Denial[%d].ToolName = %v, want %v", i, denial.ToolName, tt.expectDenials[i].ToolName)
}
if denial.ToolUseID != tt.expectDenials[i].ToolUseID {
t.Errorf("Denial[%d].ToolUseID = %v, want %v", i, denial.ToolUseID, tt.expectDenials[i].ToolUseID)
}
}
}
})
}
}
func TestPermissionDenialsToStrings(t *testing.T) {
p := PermissionDenials{
Denials: []PermissionDenial{
{ToolName: "Bash"},
{ToolName: "Write"},
},
}
strings := p.ToStrings()
expected := []string{"Bash", "Write"}
if len(strings) != len(expected) {
t.Errorf("ToStrings() returned %d items, want %d", len(strings), len(expected))
}
for i, s := range strings {
if s != expected[i] {
t.Errorf("ToStrings()[%d] = %v, want %v", i, s, expected[i])
}
}
}