Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 14 additions & 1 deletion internal/cli/run.go
Original file line number Diff line number Diff line change
Expand Up @@ -589,7 +589,20 @@ func runAgent(agentName, fullsendDir, outputBase, targetRepo, fullsendBinary str
}
}

// 9e. Post-agent output scan — redact secrets from extracted output.
// 9e-bis. Surface transcript errors in workflow logs (GitHub Actions).
// When the agent exits non-zero, parse transcript JSONL files and emit
// ::error:: annotations so operators can diagnose failures without
// downloading artifacts. See #704.
if lastExitCode != 0 {
lastIterDir := filepath.Join(runDir, fmt.Sprintf("iteration-%d", runCount))
lastTranscriptDir := filepath.Join(lastIterDir, "transcripts")
if errorSummaries := extractTranscriptErrors(lastTranscriptDir); len(errorSummaries) > 0 {
printer.StepWarn(fmt.Sprintf("Found %d transcript error(s) — emitting to workflow log", len(errorSummaries)))
emitTranscriptErrors(os.Stderr, errorSummaries)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[moderate] Noted, deferring. Emits ::error:: unconditionally. Existing pattern at line 953 gates on isCI. Consider wrapping.

}
}

// 9f. Post-agent output scan — redact secrets from extracted output.
if h.SecurityEnabled() {
printer.StepStart("Running post-agent output scan")
if err := scanOutputFiles(runDir, traceID, printer); err != nil {
Expand Down
153 changes: 153 additions & 0 deletions internal/cli/transcript.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
package cli

import (
"bufio"
"bytes"
"encoding/json"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"unicode/utf8"
)

const (
// maxTranscriptErrorLength is the maximum length of an error message
// emitted via ::error:: to avoid overwhelming workflow logs.
maxTranscriptErrorLength = 2000

// maxTranscriptLineSize is the maximum size of a single JSONL line
// we will attempt to parse. Lines larger than this are skipped to
// avoid excessive memory use on very large tool outputs.
maxTranscriptLineSize = 1024 * 1024 // 1 MB
)

// transcriptResult represents the final result event in a Claude Code
// stream-json transcript. This is the last event emitted and indicates
// whether the session ended in error.
type transcriptResult struct {
Type string `json:"type"`
Subtype string `json:"subtype,omitempty"`
IsError bool `json:"is_error"`
Result string `json:"result,omitempty"`
}

// transcriptErrorSummary holds extracted error information from a transcript.
type transcriptErrorSummary struct {
// Source is the transcript filename the error was found in.
Source string
// IsError is true when the result event has is_error set.
IsError bool
// ErrorMessage is the error text from the result event.
ErrorMessage string
// Subtype is the result subtype (e.g. "error_max_turns").
Subtype string
}

// extractTranscriptErrors scans all JSONL files in transcriptDir for
// result events with errors. Returns a summary for each transcript that
// contains an error result. Files that cannot be read or parsed are
// silently skipped — transcript extraction is best-effort.
func extractTranscriptErrors(transcriptDir string) []transcriptErrorSummary {
entries, err := os.ReadDir(transcriptDir)
if err != nil {
return nil
}

var summaries []transcriptErrorSummary

for _, entry := range entries {
if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".jsonl") {
continue
}
path := filepath.Join(transcriptDir, entry.Name())
if summary, ok := parseTranscriptFile(path); ok && summary.IsError {
summaries = append(summaries, summary)
}
}

return summaries
}

// parseTranscriptFile reads a JSONL transcript and returns the last result
// event, if any. The second return value is false if no result event was found.
func parseTranscriptFile(path string) (transcriptErrorSummary, bool) {
f, err := os.Open(path)
if err != nil {
return transcriptErrorSummary{}, false
}
defer f.Close()

var lastResult *transcriptResult
scanner := bufio.NewScanner(f)
scanner.Buffer(make([]byte, 0, 64*1024), maxTranscriptLineSize)

for scanner.Scan() {
line := scanner.Bytes()
if len(line) == 0 {
continue
}

// Quick check: only parse lines that look like result events.
// This avoids unmarshalling every line in potentially large transcripts.
if !isResultLine(line) {
continue
}

var result transcriptResult
if err := json.Unmarshal(line, &result); err != nil {
continue
}
if result.Type == "result" {
lastResult = &result
}
}

if lastResult == nil {
return transcriptErrorSummary{}, false
}

return transcriptErrorSummary{
Source: filepath.Base(path),
IsError: lastResult.IsError,
ErrorMessage: truncateError(lastResult.Result),
Subtype: lastResult.Subtype,
}, true
}

// isResultLine does a fast prefix/contains check to avoid parsing every
// JSONL line. Claude Code transcripts can be very large.
func isResultLine(line []byte) bool {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[minor] isResultLine converts []byte to string via strings.Contains(string(line), ...), which allocates a new string (up to 1 MB per line, twice). Since this is meant to be a cheap pre-filter to avoid json.Unmarshal, use bytes.Contains instead to avoid the allocation:

func isResultLine(line []byte) bool {
    return bytes.Contains(line, []byte(`"type":"result"`)) ||
        bytes.Contains(line, []byte(`"type": "result"`))
}

// Result events contain "type":"result" or "type": "result".
return bytes.Contains(line, []byte(`"type":"result"`)) ||
bytes.Contains(line, []byte(`"type": "result"`))
}

// truncateError trims an error message to maxTranscriptErrorLength.
// If truncated, walks back to a valid UTF-8 rune boundary before
// appending an ellipsis indicator.
func truncateError(msg string) string {
if len(msg) <= maxTranscriptErrorLength {
return msg
}
truncated := msg[:maxTranscriptErrorLength]
for len(truncated) > 0 && !utf8.Valid([]byte(truncated)) {
truncated = truncated[:len(truncated)-1]
}
return truncated + "… (truncated)"
}

// emitTranscriptErrors writes ::error:: annotations for each transcript
// error summary. These appear in the GitHub Actions job summary, making
// agent failures diagnosable without downloading artifacts.
func emitTranscriptErrors(w io.Writer, summaries []transcriptErrorSummary) {
for _, s := range summaries {
// Sanitize the error message to prevent GHA command injection.
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[critical] s.Source (derived from the JSONL filename via filepath.Base) is interpolated directly into the ::error:: workflow command without sanitization. Only msg goes through sanitizeOutput. A crafted filename containing %0A or :: sequences could inject additional GHA commands.

The fallback on line 142 has the same issue with s.Subtype.

Fix:

fmt.Fprintf(w, "::error title=Agent Error (%s)::%s\n", sanitizeOutput(s.Source), msg)

And for the fallback:

msg = fmt.Sprintf("agent terminated with error (subtype: %s)", sanitizeOutput(s.Subtype))

msg := sanitizeOutput(s.ErrorMessage)
if msg == "" {
msg = fmt.Sprintf("agent terminated with error (subtype: %s)", sanitizeOutput(s.Subtype))
}
fmt.Fprintf(w, "::error title=Agent Error (%s)::%s\n", sanitizeOutput(s.Source), msg)
}
}
Loading
Loading