Skip to content

Commit 0a9ae39

Browse files
Copilotdavidslater
andauthored
fix: robustly parse THREAT_DETECTION_RESULT with literal newlines in reasons (#issue) (#22982)
* fix: robustly parse THREAT_DETECTION_RESULT with literal newlines in reasons - Add extractResultFromText helper using brace-counting (no regex) to extract the complete JSON object even when reasons contain literal newlines - extractFromStreamJson now rejoins split lines and uses brace-counting instead of returning the first truncated line - parseDetectionLog raw mode uses same join+brace-count approach; falls back to trimmed line for non-object JSON (null, [], truncated) to preserve error messages - Normalize literal newlines to \n escape before JSON.parse to handle unescaped newlines introduced by outer stream-json decoder - Replace first-5/last-5 debug lines with line count + THREAT_DETECTION_RESULT lines - Add test for literal-newline-in-reasons case covering both extractFromStreamJson and full parseDetectionLog flow Agent-Logs-Url: https://github.com/github/gh-aw/sessions/3c9f11d1-b986-441a-a4db-8bfbf6723a0f Co-authored-by: davidslater <12449447+davidslater@users.noreply.github.com> * fix: address code review - use split/join for line counting, export and test extractResultFromText Agent-Logs-Url: https://github.com/github/gh-aw/sessions/3c9f11d1-b986-441a-a4db-8bfbf6723a0f Co-authored-by: davidslater <12449447+davidslater@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: davidslater <12449447+davidslater@users.noreply.github.com> Co-authored-by: David Slater <davidslater@users.noreply.github.com>
1 parent 7fd4d21 commit 0a9ae39

2 files changed

Lines changed: 196 additions & 23 deletions

File tree

actions/setup/js/parse_threat_detection_results.cjs

Lines changed: 113 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,60 @@ const { ERR_SYSTEM, ERR_PARSE, ERR_VALIDATION } = require("./error_codes.cjs");
2525

2626
const 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 };

actions/setup/js/parse_threat_detection_results.test.cjs

Lines changed: 83 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,58 @@ const mockCore = {
3333
};
3434
global.core = mockCore;
3535

36-
const { parseDetectionLog, extractFromStreamJson } = require("./parse_threat_detection_results.cjs");
36+
const { parseDetectionLog, extractFromStreamJson, extractResultFromText } = require("./parse_threat_detection_results.cjs");
37+
38+
describe("extractResultFromText", () => {
39+
it("should extract a simple JSON object", () => {
40+
const text = 'THREAT_DETECTION_RESULT:{"prompt_injection":false,"reasons":[]}';
41+
const result = extractResultFromText(text);
42+
expect(result).toBe('THREAT_DETECTION_RESULT:{"prompt_injection":false,"reasons":[]}');
43+
});
44+
45+
it("should stop at the matching closing brace and ignore trailing content", () => {
46+
const text = 'THREAT_DETECTION_RESULT:{"prompt_injection":false,"reasons":[]}\nSome trailing text';
47+
const result = extractResultFromText(text);
48+
expect(result).toBe('THREAT_DETECTION_RESULT:{"prompt_injection":false,"reasons":[]}');
49+
expect(result).not.toContain("trailing");
50+
});
51+
52+
it("should handle nested objects correctly", () => {
53+
const text = 'THREAT_DETECTION_RESULT:{"a":{"b":{"c":1}}}';
54+
const result = extractResultFromText(text);
55+
expect(result).toBe('THREAT_DETECTION_RESULT:{"a":{"b":{"c":1}}}');
56+
});
57+
58+
it("should not count braces inside JSON string values", () => {
59+
const text = 'THREAT_DETECTION_RESULT:{"reasons":["found {injection} here"]}';
60+
const result = extractResultFromText(text);
61+
expect(result).toBe('THREAT_DETECTION_RESULT:{"reasons":["found {injection} here"]}');
62+
});
63+
64+
it("should handle escaped quotes inside strings", () => {
65+
const text = 'THREAT_DETECTION_RESULT:{"reasons":["he said \\"hello\\""]}';
66+
const result = extractResultFromText(text);
67+
expect(result).toBe('THREAT_DETECTION_RESULT:{"reasons":["he said \\"hello\\""]}');
68+
});
69+
70+
it("should handle actual newlines inside string values", () => {
71+
const text = 'THREAT_DETECTION_RESULT:{"reasons":["line one\nline two"]}trailing';
72+
const result = extractResultFromText(text);
73+
expect(result).toBe('THREAT_DETECTION_RESULT:{"reasons":["line one\nline two"]}');
74+
});
75+
76+
it("should return null when no opening brace found", () => {
77+
expect(extractResultFromText("THREAT_DETECTION_RESULT:null")).toBeNull();
78+
expect(extractResultFromText("THREAT_DETECTION_RESULT:[]")).toBeNull();
79+
expect(extractResultFromText("THREAT_DETECTION_RESULT:42")).toBeNull();
80+
expect(extractResultFromText("THREAT_DETECTION_RESULT:")).toBeNull();
81+
});
82+
83+
it("should return null when closing brace is missing (truncated JSON)", () => {
84+
expect(extractResultFromText('THREAT_DETECTION_RESULT:{"key":')).toBeNull();
85+
expect(extractResultFromText('THREAT_DETECTION_RESULT:{"prompt_injection":true')).toBeNull();
86+
});
87+
});
3788

3889
describe("extractFromStreamJson", () => {
3990
it("should extract result from type:result JSON envelope", () => {
@@ -91,6 +142,37 @@ describe("extractFromStreamJson", () => {
91142
it("should return null for malformed JSON", () => {
92143
expect(extractFromStreamJson("{not valid json}")).toBeNull();
93144
});
145+
146+
it("should handle reasons values with literal newlines introduced by outer JSON.parse", () => {
147+
// When the model output contains a reason string with an actual newline character,
148+
// stream-json encodes it as \n (JSON escape) in the result field.
149+
// After the outer JSON.parse, \n becomes an actual newline, splitting the verdict
150+
// JSON across multiple lines when we split obj.result by "\n".
151+
// The fix: rejoin lines from the prefix line onward and use brace-counting to
152+
// extract the complete JSON object.
153+
const resultWithLiteralNewline = 'THREAT_DETECTION_RESULT:{"prompt_injection":true,"secret_leak":false,"malicious_patch":false,"reasons":["Found injection in\nline 5"]}';
154+
// JSON.stringify encodes the actual newline as \n in the outer JSON, matching
155+
// how the stream-json format represents it on disk as a single log line.
156+
const logLine = JSON.stringify({ type: "result", subtype: "success", result: resultWithLiteralNewline });
157+
158+
// Verify extractFromStreamJson returns the complete result (not truncated at the newline)
159+
const extracted = extractFromStreamJson(logLine);
160+
expect(extracted).not.toBeNull();
161+
expect(extracted).toMatch(/^THREAT_DETECTION_RESULT:/);
162+
expect(extracted).toContain("line 5");
163+
164+
// Verify the full verdict parses correctly when the log line is passed to parseDetectionLog
165+
const { verdict, error } = parseDetectionLog(logLine);
166+
expect(error).toBeUndefined();
167+
expect(verdict).toBeDefined();
168+
expect(verdict.prompt_injection).toBe(true);
169+
expect(verdict.secret_leak).toBe(false);
170+
expect(verdict.malicious_patch).toBe(false);
171+
expect(verdict.reasons.length).toBeGreaterThan(0);
172+
// The newline in the reason should be preserved in the parsed output
173+
expect(verdict.reasons[0]).toContain("Found injection in");
174+
expect(verdict.reasons[0]).toContain("line 5");
175+
});
94176
});
95177

96178
describe("parseDetectionLog", () => {

0 commit comments

Comments
 (0)