Commit b3fa69955 for llama.cpp

commit b3fa69955ea81c3b60e77e3c1e2224c4a826ffb9
Author: Yuri Khrustalev <ykhrustalev@users.noreply.github.com>
Date:   Sun Oct 25 01:46:35 2026 -0400

    chat : reject JSON nested more than 128 levels before walking it (#30242)

    * chat : reject JSON nested more than 128 levels before walking it

    The schema builder, the jinja engine and the decision code walk request
    JSON with recursion. A request nested deep enough overflows the stack of
    the HTTP thread and llama-server dies (SIGBUS on macOS, SIGSEGV on Linux):
    for a tool schema from a few hundred levels on macOS and about 10000 on
    Linux.

    Check the depth first and answer 400 past 128 levels: the chat inputs in
    common_chat_templates_apply (tools, tool-call arguments,
    chat_template_kwargs, json_schema), the json_schema of /completion, and the
    whole /v1/systemone body. A gemma4 tool result that is too deep stays text.

    * fix the root, not the call side

    ---------

    Co-authored-by: Xuan Son Nguyen <son@huggingface.co>

diff --git a/common/jinja/value.cpp b/common/jinja/value.cpp
index 96b9bd03d..e65c20a45 100644
--- a/common/jinja/value.cpp
+++ b/common/jinja/value.cpp
@@ -6,6 +6,7 @@
 #include "json.h"

 #include <sstream>
+#include <stdexcept>
 #include <string>
 #include <cctype>
 #include <cfloat>
@@ -1439,7 +1440,10 @@ const func_builtins & value_undefined_t::get_builtins() const {
 //////////////////////////////////


-static value from_json(const common_json & j, bool mark_input) {
+// the runtime walks values with recursion, a deeper input overflows the stack
+static constexpr int FROM_JSON_MAX_DEPTH = 128;
+
+static value from_json(const common_json & j, bool mark_input, int depth = 0) {
     if (j.is_null()) {
         return mk_val<value_none>();
     } else if (j.is_boolean()) {
@@ -1454,16 +1458,18 @@ static value from_json(const common_json & j, bool mark_input) {
             str->mark_input();
         }
         return str;
+    } else if ((j.is_array() || j.is_object()) && depth >= FROM_JSON_MAX_DEPTH) {
+        throw std::invalid_argument("JSON is nested more than " + std::to_string(FROM_JSON_MAX_DEPTH) + " levels deep");
     } else if (j.is_array()) {
         auto arr = mk_val<value_array>();
         for (const auto & item : j) {
-            arr->push_back(from_json(item, mark_input));
+            arr->push_back(from_json(item, mark_input, depth + 1));
         }
         return arr;
     } else if (j.is_object()) {
         auto obj = mk_val<value_object>();
         for (auto it = j.begin(); it != j.end(); ++it) {
-            obj->insert(it.key(), from_json(it.value(), mark_input));
+            obj->insert(it.key(), from_json(it.value(), mark_input, depth + 1));
         }
         return obj;
     } else {
diff --git a/common/json-schema.cpp b/common/json-schema.cpp
index 6898840e7..e1bfa79df 100644
--- a/common/json-schema.cpp
+++ b/common/json-schema.cpp
@@ -224,7 +224,21 @@ class common_chat_schema_builder {
         return node;
     }

+    // the builder and the code that walks the built schema recurse once per level, a deeper schema overflows the stack
+    static constexpr int max_depth = 128;
+    int                  depth_    = 0;
+
     common_chat_schema_ptr build_node(const common_json & schema, const std::string & path) {
+        if (depth_ >= max_depth) {
+            throw std::invalid_argument("JSON schema is nested more than " + std::to_string(max_depth) + " levels deep");
+        }
+        depth_++;
+        auto node = build_node_impl(schema, path);
+        depth_--;
+        return node;
+    }
+
+    common_chat_schema_ptr build_node_impl(const common_json & schema, const std::string & path) {
         if (!schema.is_object()) {
             fail(path, "schema must be an object");
         }
diff --git a/tests/test-chat.cpp b/tests/test-chat.cpp
index 0de475132..10a477fa4 100644
--- a/tests/test-chat.cpp
+++ b/tests/test-chat.cpp
@@ -10,6 +10,8 @@
 #include "../tools/server/server-chat.h"
 #include "chat-auto-parser.h"
 #include "chat.h"
+#include "json-schema-to-grammar.h"
+#include "json-schema.h"
 #include "common.h"
 #include "ggml.h"
 #include "log.h"
@@ -8106,6 +8108,58 @@ static void test_chat_session() {
     }
 }

+static void test_chat_json_depth() {
+    LOG_DBG("%s\n", __func__);
+    const auto arrays = [](size_t n) {
+        return std::string(n, '[') + std::string(n, ']');
+    };
+    // a schema of n nested array types
+    const auto schema = [](size_t n) {
+        std::string s;
+        for (size_t i = 0; i < n; i++) {
+            s += R"({"type": "array", "items": )";
+        }
+        return s + R"({"type": "string"})" + std::string(n, '}');
+    };
+    const auto rejected = [](const std::function<void()> & fn) {
+        try {
+            fn();
+        } catch (const std::invalid_argument & e) {
+            return std::string(e.what()).find("nested more than 128 levels deep") != std::string::npos;
+        }
+        return false;
+    };
+
+    // the schema builder stops at 128 levels, the root schema is one of them
+    assert_equals(false, rejected([&] { common_chat_schema_from_json(json::parse(schema(127))); }));
+    assert_equals(true, rejected([&] { common_chat_schema_from_json(json::parse(schema(128))); }));
+    assert_equals(true, rejected([&] { json_schema_to_grammar(json::parse(schema(1000))); }));
+
+    // the jinja engine and the schema builder reject the inputs before they recurse too deep
+    auto         tmpls = read_templates("models/templates/Qwen3.5-4B.jinja");
+    const size_t deep  = 1000;
+
+    common_chat_templates_inputs tool_schema;
+    tool_schema.messages = { message_user };
+    tool_schema.tools    = { { "f", "", R"({"type": "object", "properties": {"x": )" + schema(deep) + "}}" } };
+    assert_equals(true, rejected([&] { common_chat_templates_apply(tmpls.get(), tool_schema); }));
+
+    common_chat_templates_inputs tool_args;
+    tool_args.messages = { message_user, simple_assist_msg("", "", "special_function", R"({"arg1": )" + arrays(deep) + "}") };
+    tool_args.tools    = { special_function_tool };
+    assert_equals(true, rejected([&] { common_chat_templates_apply(tmpls.get(), tool_args); }));
+
+    common_chat_templates_inputs kwargs;
+    kwargs.messages                     = { message_user };
+    kwargs.chat_template_kwargs["deep"] = arrays(deep);
+    assert_equals(true, rejected([&] { common_chat_templates_apply(tmpls.get(), kwargs); }));
+
+    common_chat_templates_inputs json_schema;
+    json_schema.messages    = { message_user };
+    json_schema.json_schema = schema(deep);
+    assert_equals(true, rejected([&] { common_chat_templates_apply(tmpls.get(), json_schema); }));
+}
+
 static void test_reasoning_budget_tokens_per_request() {
     LOG_DBG("%s\n", __func__);
     // Use Qwen3 template which has <think>...</think> reasoning markers.
@@ -8355,6 +8409,7 @@ int main(int argc, char ** argv) {
         test_reasoning_effort_caps();
         test_prompt_parts();
         test_chat_session();
+        test_chat_json_depth();
         test_reasoning_budget_tokens_per_request();
         test_reasoning_budget_message_per_request();
         test_template_output_peg_parsers(detailed_debug);
diff --git a/tests/test-server-component.cpp b/tests/test-server-component.cpp
index 18e0cdc2f..92eeec055 100644
--- a/tests/test-server-component.cpp
+++ b/tests/test-server-component.cpp
@@ -464,6 +464,32 @@ static void test_rerank_template(testing & t) {
     }
 }

+//
+// server_decision_context: the recursive functions stop at RECURSIVE_LIMIT
+//
+
+static void test_decision_depth(testing & t) {
+    const auto prepare = [](size_t depth) {
+        server_decision_context ctx;
+        ctx.type = COMMON_DECISION_TYPE_H2O_LIGHTNING; // the state goes through decision_py_dump
+        std::string error;
+        try {
+            ctx.prepare_state(json::parse(std::string(depth, '[') + std::string(depth, ']')));
+        } catch (const std::invalid_argument & e) {
+            error = e.what();
+        }
+        return error;
+    };
+    t.test("state at the limit", [&](testing & t) {
+        const std::string error = prepare(128);
+        t.assert_true("accepted: " + error, error.empty());
+    });
+    t.test("deep state", [&](testing & t) {
+        const std::string error = prepare(1000);
+        t.assert_true("rejected as nested: " + error, error.find("nested more than 128 levels deep") != std::string::npos);
+    });
+}
+
 int main(int argc, char ** argv) {
     testing t;

@@ -484,6 +510,7 @@ int main(int argc, char ** argv) {
     t.test("server_pipe",   test_server_pipe);
     t.test("decision_escape", test_decision_escape);
     t.test("rerank_template", test_rerank_template);
+    t.test("decision_depth",  test_decision_depth);

     return t.summary();
 }
diff --git a/tools/server/server-decision.cpp b/tools/server/server-decision.cpp
index ce4cb4d09..5fd7585ff 100644
--- a/tools/server/server-decision.cpp
+++ b/tools/server/server-decision.cpp
@@ -23,6 +23,9 @@ static const size_t DECISION_LEV_N_RATINGS = 9;
 // a longer state keeps its head and its tail
 static const size_t DECISION_H2O_MAX_STATE_TOKENS = 32000;

+// the recursive functions of this file walk request JSON, a deeper value would overflow the stack
+static const int RECURSIVE_LIMIT = 128;
+
 static std::string decision_meta_str(const llama_model * model, const std::string & key) {
     char buf[256];
     const int32_t n = llama_model_meta_val_str(model, key.c_str(), buf, sizeof(buf));
@@ -417,7 +420,10 @@ json server_decision_context::parse_state(const json & body, std::vector<raw_buf
 //

 // replace text in all strings of a JSON value
-static json decision_replace_text(const json & val, const std::string & search, const std::string & replace) {
+static json decision_replace_text(const json & val, const std::string & search, const std::string & replace, int depth = 0) {
+    if (depth >= RECURSIVE_LIMIT) {
+        throw std::invalid_argument("JSON is nested more than " + std::to_string(RECURSIVE_LIMIT) + " levels deep");
+    }
     if (val.is_string()) {
         std::string str = val.get<std::string>();
         string_replace_all(str, search, replace);
@@ -426,14 +432,14 @@ static json decision_replace_text(const json & val, const std::string & search,
     if (val.is_array()) {
         json out = json::array();
         for (const auto & item : val) {
-            out.push_back(decision_replace_text(item, search, replace));
+            out.push_back(decision_replace_text(item, search, replace, depth + 1));
         }
         return out;
     }
     if (val.is_object()) {
         json out = json::object();
         for (const auto & [key, item] : val.items()) {
-            out[key] = decision_replace_text(item, search, replace);
+            out[key] = decision_replace_text(item, search, replace, depth + 1);
         }
         return out;
     }
@@ -441,18 +447,21 @@ static json decision_replace_text(const json & val, const std::string & search,
 }

 // sort the keys of all objects of a JSON value
-static json decision_sort_keys(const json & val) {
+static json decision_sort_keys(const json & val, int depth = 0) {
+    if (depth >= RECURSIVE_LIMIT) {
+        throw std::invalid_argument("JSON is nested more than " + std::to_string(RECURSIVE_LIMIT) + " levels deep");
+    }
     if (val.is_array()) {
         json out = json::array();
         for (const auto & item : val) {
-            out.push_back(decision_sort_keys(item));
+            out.push_back(decision_sort_keys(item, depth + 1));
         }
         return out;
     }
     if (val.is_object()) {
         std::map<std::string, json> sorted;
         for (const auto & [key, item] : val.items()) {
-            sorted[key] = decision_sort_keys(item);
+            sorted[key] = decision_sort_keys(item, depth + 1);
         }
         json out = json::object();
         for (const auto & [key, item] : sorted) {
@@ -464,7 +473,10 @@ static json decision_sort_keys(const json & val) {
 }

 // kev flattens a JSON value into text, the keys of an object are kept as labels (kev/api.py: render)
-static std::string decision_kev_render(const json & val, int indent = 0) {
+static std::string decision_kev_render(const json & val, int indent = 0, int depth = 0) {
+    if (depth >= RECURSIVE_LIMIT) {
+        throw std::invalid_argument("JSON is nested more than " + std::to_string(RECURSIVE_LIMIT) + " levels deep");
+    }
     const std::string pad(2 * indent, ' ');
     if (val.is_null()) {
         return "";
@@ -478,7 +490,7 @@ static std::string decision_kev_render(const json & val, int indent = 0) {
     if (val.is_array()) {
         std::string out;
         for (const auto & item : val) {
-            const std::string text = decision_kev_render(item, indent + 1);
+            const std::string text = decision_kev_render(item, indent + 1, depth + 1);
             out += (out.empty() ? "" : "\n") + pad + "- " + text.substr(std::min(text.size(), text.find_first_not_of(" \t\n\r")));
         }
         return out;
@@ -487,7 +499,7 @@ static std::string decision_kev_render(const json & val, int indent = 0) {
         std::string out;
         for (const auto & [key, item] : val.items()) {
             const bool is_nested = item.is_object() || item.is_array();
-            out += (out.empty() ? "" : "\n") + pad + key + (is_nested ? ":\n" : ": ") + decision_kev_render(item, is_nested ? indent + 1 : 0);
+            out += (out.empty() ? "" : "\n") + pad + key + (is_nested ? ":\n" : ": ") + decision_kev_render(item, is_nested ? indent + 1 : 0, depth + 1);
         }
         return out;
     }
@@ -528,21 +540,24 @@ static std::string decision_kev_text(const json & val) {
 }

 // lfm2-d1-omni: special tokens written in the input must not be parsed as such, in keys too (d1-omni prompt.py: escape)
-static json decision_d1omni_escape(const json & val) {
+static json decision_d1omni_escape(const json & val, int depth = 0) {
+    if (depth >= RECURSIVE_LIMIT) {
+        throw std::invalid_argument("JSON is nested more than " + std::to_string(RECURSIVE_LIMIT) + " levels deep");
+    }
     if (val.is_string()) {
         return server_decision_escape_special_tokens(val.get<std::string>());
     }
     if (val.is_array()) {
         json out = json::array();
         for (const auto & item : val) {
-            out.push_back(decision_d1omni_escape(item));
+            out.push_back(decision_d1omni_escape(item, depth + 1));
         }
         return out;
     }
     if (val.is_object()) {
         json out = json::object();
         for (const auto & [key, item] : val.items()) {
-            out[server_decision_escape_special_tokens(key)] = decision_d1omni_escape(item);
+            out[server_decision_escape_special_tokens(key)] = decision_d1omni_escape(item, depth + 1);
         }
         return out;
     }
@@ -669,6 +684,9 @@ static void decision_py_str(std::string & out, const std::string & s, bool ensur

 // json.dumps() of Python: indent < 0 is no indent, keys stay in their order
 static void decision_py_dump(std::string & out, const json & val, int indent, int level, const std::string & item_sep, const std::string & key_sep, bool ensure_ascii) {
+    if (level >= RECURSIVE_LIMIT) {
+        throw std::invalid_argument("JSON is nested more than " + std::to_string(RECURSIVE_LIMIT) + " levels deep");
+    }
     const auto newline = [&](int lvl) {
         return indent < 0 ? std::string() : "\n" + std::string(indent * lvl, ' ');
     };