Skip to content

Commit 693d244

Browse files
authored
Merge pull request #13 from Deep-CodeAI/fix/parser-coercion-bounds
Fix/parser coercion bounds
2 parents 313b4ca + 8bcb932 commit 693d244

4 files changed

Lines changed: 233 additions & 4 deletions

File tree

src/main/kotlin/agents_engine/generation/GenerableSupport.kt

Lines changed: 57 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -297,8 +297,13 @@ private fun coerceValue(value: Any?, type: KType): Any? {
297297
if (value == null) return null
298298
return when (type.classifier) {
299299
String::class -> value.toString()
300-
Int::class -> (value as? Number)?.toInt()
301-
Long::class -> (value as? Number)?.toLong()
300+
// #855 — `Number.toInt()` and `Number.toLong()` truncate silently on overflow.
301+
// Reject out-of-range values so the LLM can't slip a 99_999_999_999 into an
302+
// `Int` field and end up with garbage. Returning null routes through
303+
// `constructFromMap` → `onToolError.invalidArgs`, which is the right
304+
// recovery path for "this value didn't match the type contract".
305+
Int::class -> coerceToInt(value)
306+
Long::class -> coerceToLong(value)
302307
Double::class -> (value as? Number)?.toDouble()
303308
Float::class -> (value as? Number)?.toFloat()
304309
Boolean::class -> value as? Boolean
@@ -317,3 +322,53 @@ private fun coerceValue(value: Any?, type: KType): Any? {
317322
}
318323
}
319324
}
325+
326+
/**
327+
* Coerce a JSON-decoded value to `Int` without silent truncation. Returns null when
328+
* the input is not a `Number`, when a fractional part would be lost, or when the
329+
* value is outside `Int.MIN_VALUE..Int.MAX_VALUE`. See #855.
330+
*/
331+
private fun coerceToInt(value: Any): Int? {
332+
val n = value as? Number ?: return null
333+
val asLong = when (n) {
334+
is Long, is Int, is Short, is Byte -> n.toLong()
335+
is Double -> {
336+
if (n.isNaN() || n.isInfinite() || n != Math.floor(n)) return null
337+
if (n < Int.MIN_VALUE.toDouble() || n > Int.MAX_VALUE.toDouble()) return null
338+
n.toLong()
339+
}
340+
is Float -> {
341+
val d = n.toDouble()
342+
if (d.isNaN() || d.isInfinite() || d != Math.floor(d)) return null
343+
if (d < Int.MIN_VALUE.toDouble() || d > Int.MAX_VALUE.toDouble()) return null
344+
d.toLong()
345+
}
346+
else -> n.toLong()
347+
}
348+
if (asLong !in Int.MIN_VALUE..Int.MAX_VALUE) return null
349+
return asLong.toInt()
350+
}
351+
352+
/**
353+
* Coerce a JSON-decoded value to `Long` without silent truncation. Returns null on
354+
* non-numeric input, fractional input, or out-of-range floating-point input. See #855.
355+
*/
356+
private fun coerceToLong(value: Any): Long? {
357+
val n = value as? Number ?: return null
358+
return when (n) {
359+
is Long -> n
360+
is Int, is Short, is Byte -> n.toLong()
361+
is Double -> {
362+
if (n.isNaN() || n.isInfinite() || n != Math.floor(n)) return null
363+
if (n < Long.MIN_VALUE.toDouble() || n > Long.MAX_VALUE.toDouble()) return null
364+
n.toLong()
365+
}
366+
is Float -> {
367+
val d = n.toDouble()
368+
if (d.isNaN() || d.isInfinite() || d != Math.floor(d)) return null
369+
if (d < Long.MIN_VALUE.toDouble() || d > Long.MAX_VALUE.toDouble()) return null
370+
d.toLong()
371+
}
372+
else -> n.toLong()
373+
}
374+
}

src/main/kotlin/agents_engine/generation/LenientJsonParser.kt

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,16 @@ package agents_engine.generation
88
*/
99
internal object LenientJsonParser {
1010

11+
/**
12+
* Hard cap on nesting depth — see #854. Without it, input like
13+
* `{"a":{"a":{...nested 10000 times...}}}` overflows the JVM stack
14+
* (`StackOverflowError` is an `Error`, not an `Exception` — it's NOT
15+
* caught by the try/catch in [parse]). 64 levels is comfortably more
16+
* than any legitimate LLM-emitted structure and keeps stack usage in
17+
* the kilobytes.
18+
*/
19+
const val MAX_NESTING_DEPTH: Int = 64
20+
1121
fun parse(input: String): Any? {
1222
val block = extractJsonBlock(input)
1323
if (block.isEmpty() || (block[0] != '{' && block[0] != '[')) return null
@@ -55,20 +65,33 @@ internal object LenientJsonParser {
5565

5666
private class Parser(private val s: String) {
5767
private var pos = 0
68+
private var depth = 0
5869

5970
fun parseValue(): Any? {
6071
skipWs()
6172
if (pos >= s.length) return null
6273
return when (s[pos]) {
63-
'{' -> parseObject()
64-
'[' -> parseArray()
74+
'{' -> withDepth { parseObject() }
75+
'[' -> withDepth { parseArray() }
6576
'"' -> parseString()
6677
't', 'f' -> parseBoolean()
6778
'n' -> parseNull()
6879
else -> parseNumber()
6980
}
7081
}
7182

83+
private inline fun <T> withDepth(block: () -> T): T {
84+
if (depth >= MAX_NESTING_DEPTH) {
85+
throw IllegalStateException("JSON nesting exceeds $MAX_NESTING_DEPTH")
86+
}
87+
depth++
88+
try {
89+
return block()
90+
} finally {
91+
depth--
92+
}
93+
}
94+
7295
private fun parseObject(): Map<String, Any?> {
7396
pos++ // consume '{'
7497
val map = linkedMapOf<String, Any?>()
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
package agents_engine.generation
2+
3+
import kotlin.test.Test
4+
import kotlin.test.assertEquals
5+
import kotlin.test.assertNotNull
6+
import kotlin.test.assertNull
7+
8+
// Tests for #855 — coerceValue rejects out-of-range or fractional inputs for
9+
// Int / Long fields instead of silently truncating. Out-of-range routes to
10+
// constructFromMap returning null, which surfaces as an invalidArgs error.
11+
12+
@Generable("box of an Int") data class IntBox(val n: Int)
13+
@Generable("box of a Long") data class LongBox(val n: Long)
14+
15+
class CoerceValueOverflowTest {
16+
17+
@Test
18+
fun `Int field rejects value larger than Int_MAX_VALUE`() {
19+
// 99_999_999_999 truncated to Int gives -1474836425 — silent corruption.
20+
val result = IntBox::class.constructFromMap(mapOf("n" to 99_999_999_999L))
21+
assertNull(result, "out-of-range Int must reject construction, not silently truncate")
22+
}
23+
24+
@Test
25+
fun `Int field rejects value below Int_MIN_VALUE`() {
26+
val result = IntBox::class.constructFromMap(mapOf("n" to -99_999_999_999L))
27+
assertNull(result, "below-range Int must reject construction")
28+
}
29+
30+
@Test
31+
fun `Int field accepts Int_MAX_VALUE exactly`() {
32+
val result = IntBox::class.constructFromMap(mapOf("n" to Int.MAX_VALUE.toLong()))
33+
assertNotNull(result)
34+
assertEquals(Int.MAX_VALUE, result!!.n)
35+
}
36+
37+
@Test
38+
fun `Int field accepts Int_MIN_VALUE exactly`() {
39+
val result = IntBox::class.constructFromMap(mapOf("n" to Int.MIN_VALUE.toLong()))
40+
assertNotNull(result)
41+
assertEquals(Int.MIN_VALUE, result!!.n)
42+
}
43+
44+
@Test
45+
fun `Int field rejects fractional Double input`() {
46+
val result = IntBox::class.constructFromMap(mapOf("n" to 1.5))
47+
assertNull(result, "fractional Double must reject coercion to Int")
48+
}
49+
50+
@Test
51+
fun `Int field accepts whole-number Double`() {
52+
val result = IntBox::class.constructFromMap(mapOf("n" to 42.0))
53+
assertNotNull(result)
54+
assertEquals(42, result!!.n)
55+
}
56+
57+
@Test
58+
fun `Int field rejects NaN and Infinity`() {
59+
assertNull(IntBox::class.constructFromMap(mapOf("n" to Double.NaN)))
60+
assertNull(IntBox::class.constructFromMap(mapOf("n" to Double.POSITIVE_INFINITY)))
61+
assertNull(IntBox::class.constructFromMap(mapOf("n" to Double.NEGATIVE_INFINITY)))
62+
}
63+
64+
@Test
65+
fun `Long field rejects fractional Double input`() {
66+
val result = LongBox::class.constructFromMap(mapOf("n" to 1.5))
67+
assertNull(result, "fractional Double must reject coercion to Long")
68+
}
69+
70+
@Test
71+
fun `Long field accepts integer-valued Double within range`() {
72+
val result = LongBox::class.constructFromMap(mapOf("n" to 9_000_000_000_000.0))
73+
assertNotNull(result)
74+
assertEquals(9_000_000_000_000L, result!!.n)
75+
}
76+
77+
@Test
78+
fun `Long field rejects out-of-range Double`() {
79+
// 1e30 is well outside Long range.
80+
val result = LongBox::class.constructFromMap(mapOf("n" to 1e30))
81+
assertNull(result, "out-of-range Double must reject coercion to Long")
82+
}
83+
84+
@Test
85+
fun `Long field accepts Int and Long values transparently`() {
86+
assertEquals(42L, LongBox::class.constructFromMap(mapOf("n" to 42))!!.n)
87+
assertEquals(42L, LongBox::class.constructFromMap(mapOf("n" to 42L))!!.n)
88+
}
89+
}
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
package agents_engine.generation
2+
3+
import kotlin.test.Test
4+
import kotlin.test.assertEquals
5+
import kotlin.test.assertNotNull
6+
import kotlin.test.assertNull
7+
8+
// Tests for #854 — LenientJsonParser caps nesting depth so a malicious payload
9+
// can't overflow the JVM stack.
10+
class LenientJsonParserDepthLimitTest {
11+
12+
@Test
13+
fun `extreme nesting returns null instead of crashing with StackOverflowError`() {
14+
// 10 000 levels — would overflow without the depth cap.
15+
val deep = "{" + "\"a\":{".repeat(10_000) + "1" + "}".repeat(10_001)
16+
assertNull(LenientJsonParser.parse(deep), "extreme nesting must return null, not throw")
17+
}
18+
19+
@Test
20+
fun `nesting at exactly the cap parses successfully`() {
21+
// The cap is on Parser internals — depth counts each open `{`/`[` Parser
22+
// descends into. With MAX_NESTING_DEPTH = 64, 63 nested objects + leaf parse.
23+
val depth = LenientJsonParser.MAX_NESTING_DEPTH - 1
24+
val capJson = "{" + "\"a\":{".repeat(depth - 1) + "\"x\":1" + "}".repeat(depth)
25+
val r = LenientJsonParser.parse(capJson)
26+
assertNotNull(r, "at-cap input must parse")
27+
}
28+
29+
@Test
30+
fun `nesting one past the cap returns null`() {
31+
val depth = LenientJsonParser.MAX_NESTING_DEPTH + 1
32+
val overCap = "{" + "\"a\":{".repeat(depth - 1) + "\"x\":1" + "}".repeat(depth)
33+
assertNull(LenientJsonParser.parse(overCap), "over-cap input must return null")
34+
}
35+
36+
@Test
37+
fun `array nesting is also capped`() {
38+
val depth = LenientJsonParser.MAX_NESTING_DEPTH + 50
39+
val deepArray = "[".repeat(depth) + "1" + "]".repeat(depth)
40+
assertNull(LenientJsonParser.parse(deepArray), "deeply-nested array must return null")
41+
}
42+
43+
@Test
44+
fun `mixed object array nesting at moderate depth still parses`() {
45+
// 10 levels mixed — well under the cap.
46+
val ten = LenientJsonParser.parse("""{"a":[{"b":[{"c":[{"d":[{"e":42}]}]}]}]}""") as? Map<*, *>
47+
assertNotNull(ten, "moderate mixed nesting must parse fine")
48+
}
49+
50+
@Test
51+
fun `flat structures with many siblings are unaffected (siblings dont count as depth)`() {
52+
// 10 000 sibling fields — no depth recursion, just iteration.
53+
val flat = buildString {
54+
append("{")
55+
(0 until 10_000).joinTo(this, ",") { """"k$it":$it""" }
56+
append("}")
57+
}
58+
val r = LenientJsonParser.parse(flat) as? Map<*, *>
59+
assertNotNull(r, "wide-but-shallow input must parse")
60+
assertEquals(10_000, r!!.size)
61+
}
62+
}

0 commit comments

Comments
 (0)