@@ -25,6 +25,60 @@ const { ERR_SYSTEM, ERR_PARSE, ERR_VALIDATION } = require("./error_codes.cjs");
2525
2626const RESULT_PREFIX = "THREAT_DETECTION_RESULT:" ;
2727
28+ /**
29+ * Extract a complete JSON object from a string that starts with RESULT_PREFIX,
30+ * using character-by-character brace counting to find the matching closing brace.
31+ * Tracks string context so that braces inside JSON string values are not counted.
32+ * This avoids regex and correctly handles escape sequences (e.g. \", \\).
33+ *
34+ * The input may contain actual newline characters inside JSON string values when
35+ * the outer stream-json decoder has unescaped \n sequences. The extraction still
36+ * produces the complete JSON object; callers must normalize those newlines before
37+ * passing the JSON to JSON.parse.
38+ *
39+ * @param {string } text - String beginning with RESULT_PREFIX followed by a JSON object
40+ * @returns {string|null } RESULT_PREFIX + complete JSON object, or null if not found
41+ */
42+ function extractResultFromText ( text ) {
43+ const jsonStartPos = text . indexOf ( "{" , RESULT_PREFIX . length ) ;
44+ if ( jsonStartPos === - 1 ) return null ;
45+
46+ let depth = 0 ;
47+ let inString = false ;
48+ let escaped = false ;
49+ let jsonEndPos = - 1 ;
50+ for ( let i = jsonStartPos ; i < text . length ; i ++ ) {
51+ const ch = text [ i ] ;
52+ if ( escaped ) {
53+ escaped = false ;
54+ continue ;
55+ }
56+ if ( ch === "\\" && inString ) {
57+ escaped = true ;
58+ continue ;
59+ }
60+ if ( ch === '"' ) {
61+ inString = ! inString ;
62+ continue ;
63+ }
64+ if ( inString ) {
65+ continue ;
66+ }
67+ if ( ch === "{" ) {
68+ depth ++ ;
69+ } else if ( ch === "}" ) {
70+ depth -- ;
71+ if ( depth === 0 ) {
72+ jsonEndPos = i ;
73+ break ;
74+ }
75+ }
76+ }
77+ if ( jsonEndPos === - 1 ) return null ;
78+
79+ return RESULT_PREFIX + text . substring ( jsonStartPos , jsonEndPos + 1 ) ;
80+ }
81+
2882/**
2983 * Try to extract a THREAT_DETECTION_RESULT value from a stream-json line.
3084 * Stream-json output from Claude wraps the result in JSON envelopes like:
@@ -48,14 +102,29 @@ function extractFromStreamJson(line) {
48102 if ( obj . type === "result" && typeof obj . result === "string" ) {
49103 // The result field contains the model's full response text, which may
50104 // include analysis before the THREAT_DETECTION_RESULT line.
51- // Split by newlines and find the line that starts with the prefix.
105+ // Split by newlines to find the line that starts with the prefix.
106+ //
107+ // IMPORTANT: The outer JSON.parse unescapes \n sequences into actual newline
108+ // characters. If the model placed a literal newline inside a reasons string
109+ // value, the JSON object for the verdict gets split across multiple lines here.
110+ // To handle this robustly, we find the prefix line by index, rejoin all
111+ // subsequent lines, then use brace-counting to locate the complete JSON object.
52112 const resultLines = obj . result . split ( "\n" ) ;
53- for ( const rline of resultLines ) {
54- const rtrimmed = rline . trim ( ) ;
55- if ( rtrimmed . startsWith ( RESULT_PREFIX ) ) {
56- return rtrimmed ;
113+ let prefixLineIdx = - 1 ;
114+ for ( let i = 0 ; i < resultLines . length ; i ++ ) {
115+ if ( resultLines [ i ] . trim ( ) . startsWith ( RESULT_PREFIX ) ) {
116+ prefixLineIdx = i ;
117+ break ;
57118 }
58119 }
120+ if ( prefixLineIdx === - 1 ) return null ;
121+
122+ // Rejoin all lines from the prefix line onward so that any JSON string
123+ // values split by actual newlines are reassembled.
124+ const joined = resultLines . slice ( prefixLineIdx ) . join ( "\n" ) . trim ( ) ;
125+
126+ // Extract the complete JSON object using brace-counting.
127+ return extractResultFromText ( joined ) ;
59128 }
60129 } catch {
61130 // Not valid JSON — not a stream-json line
@@ -92,13 +161,32 @@ function parseDetectionLog(content) {
92161 }
93162 }
94163
95- // Phase 2: If no stream-json results, try raw line matching
164+ // Phase 2: If no stream-json results, try raw line matching.
165+ // Apply the same join-and-brace-count approach to handle cases where the
166+ // reasons values contain actual newlines that split the JSON across lines.
96167 const rawMatches = [ ] ;
97168 if ( streamMatches . length === 0 ) {
98- for ( const line of lines ) {
99- const trimmed = line . trim ( ) ;
100- if ( trimmed . startsWith ( RESULT_PREFIX ) ) {
101- rawMatches . push ( trimmed ) ;
169+ let i = 0 ;
170+ while ( i < lines . length ) {
171+ if ( lines [ i ] . trim ( ) . startsWith ( RESULT_PREFIX ) ) {
172+ const joined = lines . slice ( i ) . join ( "\n" ) . trim ( ) ;
173+ const extracted = extractResultFromText ( joined ) ;
174+ if ( extracted !== null ) {
175+ // Successfully extracted a complete JSON object; advance past consumed lines.
176+ rawMatches . push ( extracted ) ;
177+ // Count how many lines were consumed by this match so the loop
178+ // skips past them and does not re-match continuation lines.
179+ const jsonPart = extracted . substring ( RESULT_PREFIX . length ) ;
180+ const extraLines = jsonPart . split ( "\n" ) . length - 1 ;
181+ i += extraLines + 1 ;
182+ } else {
183+ // No complete {…} object found (e.g. null, [], string, truncated JSON);
184+ // fall back to the trimmed line so the parsing step reports a useful error.
185+ rawMatches . push ( lines [ i ] . trim ( ) ) ;
186+ i ++ ;
187+ }
188+ } else {
189+ i ++ ;
102190 }
103191 }
104192 }
@@ -122,7 +210,13 @@ function parseDetectionLog(content) {
122210
123211 const jsonPart = uniqueMatches [ 0 ] . substring ( RESULT_PREFIX . length ) ;
124212 try {
125- const parsed = JSON . parse ( jsonPart ) ;
213+ // Normalize literal newline characters to JSON escape sequences before parsing.
214+ // When the outer stream-json decoder unescapes \n sequences, actual newline
215+ // characters may end up inside JSON string values (e.g. in reasons entries).
216+ // Replacing them with the two-character sequence \n restores valid JSON so
217+ // that JSON.parse can handle them correctly.
218+ const normalizedJson = jsonPart . split ( "\n" ) . join ( "\\n" ) ;
219+ const parsed = JSON . parse ( normalizedJson ) ;
126220
127221 // The result must be a plain object, not null, an array, or a primitive.
128222 if ( parsed === null || typeof parsed !== "object" || Array . isArray ( parsed ) ) {
@@ -227,16 +321,13 @@ async function main() {
227321 const logLines = logContent . split ( "\n" ) ;
228322 core . info ( `📊 Detection log stats: ${ logLines . length } lines, ${ logContent . length } bytes` ) ;
229323
230- // Log first and last few lines for quick diagnosis without overwhelming output
231- const previewLines = 5 ;
232- if ( logLines . length > 0 ) {
233- core . info ( `📄 First ${ Math . min ( previewLines , logLines . length ) } lines of detection log:` ) ;
234- logLines . slice ( 0 , previewLines ) . forEach ( ( line , i ) => core . info ( ` [${ i + 1 } ] ${ line } ` ) ) ;
235- }
236- if ( logLines . length > previewLines * 2 ) {
237- core . info ( ` ... (${ logLines . length - previewLines * 2 } lines omitted) ...` ) ;
238- core . info ( `📄 Last ${ previewLines } lines of detection log:` ) ;
239- logLines . slice ( - previewLines ) . forEach ( ( line , i ) => core . info ( ` [${ logLines . length - previewLines + i + 1 } ] ${ line } ` ) ) ;
324+ // Log lines containing THREAT_DETECTION_RESULT for focused diagnosis
325+ const resultLineMatches = logLines . map ( ( line , i ) => ( { line, idx : i + 1 } ) ) . filter ( ( { line } ) => line . includes ( RESULT_PREFIX ) ) ;
326+ if ( resultLineMatches . length > 0 ) {
327+ core . info ( `📄 Lines containing THREAT_DETECTION_RESULT (${ resultLineMatches . length } of ${ logLines . length } ):` ) ;
328+ resultLineMatches . forEach ( ( { line, idx } ) => core . info ( ` [${ idx } ] ${ line } ` ) ) ;
329+ } else {
330+ core . info ( `📄 No lines containing THREAT_DETECTION_RESULT found in ${ logLines . length } lines` ) ;
240331 }
241332
242333 // ── Step 4: Parse the detection result ───────────────────────────────────
@@ -296,4 +387,4 @@ async function main() {
296387 core . info ( "════════════════════════════════════════════════════════" ) ;
297388}
298389
299- module . exports = { main, parseDetectionLog, extractFromStreamJson } ;
390+ module . exports = { main, parseDetectionLog, extractFromStreamJson, extractResultFromText } ;
0 commit comments