diff --git a/CHANGELOG.md b/CHANGELOG.md index c4b24764..d70a291f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,24 +8,23 @@ loosely while pre-1.0 (breaking changes can land on minor bumps). ## [Unreleased] ### Fixed -- **MCP registry `env` overrides parent keys.** Subprocess spawn skipped - secret-shaped parent keys then appended `env_extra`, leaving duplicate - `KEY=` entries. glibc/macOS `getenv` (and `execvp` PATH search) use the - first match, so registry `PATH` / `NODE_ENV` / `HTTP_PROXY` / non-secret - keys were ignored. Inherited keys that `env` sets are now omitted so - the registry value is the only copy. +- **JSON numbers require a complete fraction and exponent.** `json_parse` + no longer accepts `1.`, `1e`, `1e+`, or `-.5`. The tokenizer consumed + those incomplete tokens and `strtod` parsed a prefix (`"1e"` → 1.0), + so MCP / A2A / HTTP bodies silently coerced malformed numbers. +- **Advise-gate cancel is not a bad bearer.** `POST /v1/advise/gate` +- **MCP string JSON-RPC ids.** `parse_response` now accepts a decimal-string - **Reconcile rollback no longer wipes the workspace on a failed restore.** - **LaTeX math recursion depth.** `latex_math_to_plain` now stops converting - **Secret key/token writes do not follow a planted dest symlink.** - **Remote `--connect` base URL query/userinfo.** `normalize_api_base_url` +- **MCP registry `env` overrides parent keys.** Subprocess spawn skipped - **Intent reconcile Phase B (JIT ΔS waves).** `POST /v1/reconcile` `mode=ensure` - **A2A unary HTTP errors stay bounded.** `Client::rpc` no longer concatenates - **Remote `--connect` DELETE/PATCH body cap.** Conversation delete and - **Intent LLM prompt text cap.** `build_llm_user_prompt` now truncates - **Remote TUI: recoverable SSE `error` is not a failed turn.** `RemoteSseTurnConsumer::finish` copied accumulated `error` event text even when the terminal `done` event had `ok: true` (e.g. catalog skip of a stored agent whose JSON failed validation). `done` is authoritative: success clears the result error; failure still prefers `done.error` and falls back to prior `error` events when that field is empty. - **Unreadable TUI sessions are not empty.** `session_json_is_empty` no -- **Advise-gate cancel is not a bad bearer.** `POST /v1/advise/gate` -- **MCP string JSON-RPC ids.** `parse_response` now accepts a decimal-string ## [0.13.6] — 2026-09-21 diff --git a/CMakeLists.txt b/CMakeLists.txt index 7903bbb5..313d3545 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1725,6 +1725,20 @@ if(INDEX_BUILD_TESTS) ) add_test(NAME unit_remote_connect COMMAND unit_remote_connect) +<<<<<<< HEAD + # JSON number grammar (incomplete frac/exp). Separate from #342's + # unit_json (surrogate pairs in parse_string) so the two PRs do not + # share tests/test_json.cpp or the CMake insert after unit_logger. + add_executable(unit_json_numbers + tests/test_json_numbers.cpp + src/json.cpp + ) + target_include_directories(unit_json_numbers PRIVATE + ${CMAKE_SOURCE_DIR}/include + ${CMAKE_SOURCE_DIR}/third_party/doctest + ) + add_test(NAME unit_json_numbers COMMAND unit_json_numbers) +======= # Unary --connect DELETE/PATCH body cap. Separate binary so # unit_remote_connect stays network-free; this one stands up a # loopback server against RemoteApiClient's one-shot curl handles. @@ -1745,6 +1759,7 @@ if(INDEX_BUILD_TESTS) Threads::Threads ) add_test(NAME unit_remote_http COMMAND unit_remote_http) +>>>>>>> origin/main # tui_design.cpp includes themes_data.h — wire every unit target that # compiles it to the embed generator (same header as the main binary). diff --git a/src/json.cpp b/src/json.cpp index 32f9d0d3..57c9aef4 100644 --- a/src/json.cpp +++ b/src/json.cpp @@ -232,15 +232,31 @@ struct Parser { std::shared_ptr parse_number() { size_t start = pos; if (peek() == '-') advance(); - while (pos < src.size() && std::isdigit(static_cast(peek()))) advance(); + // RFC 8259: number = [ minus ] int [ frac ] [ exp ] + // int = 1*DIGIT (leading zeros remain accepted — existing) + // frac = decimal-point 1*DIGIT + // exp = e [ minus / plus ] 1*DIGIT + // The previous tokenizer consumed `1.` / `1e` / `1e+` / `-.5`. + // strtod then parsed a prefix (`"1e"` → 1.0, end at `'e'`) so + // the document was accepted. Fail closed: every present frac + // or exp must include at least one digit, and strtod must + // consume the whole token. + auto is_digit = [&]() { + return pos < src.size() && + std::isdigit(static_cast(peek())); + }; + if (!is_digit()) error("invalid number"); + while (is_digit()) advance(); if (peek() == '.') { advance(); - while (pos < src.size() && std::isdigit(static_cast(peek()))) advance(); + if (!is_digit()) error("invalid number"); + while (is_digit()) advance(); } if (peek() == 'e' || peek() == 'E') { advance(); if (peek() == '+' || peek() == '-') advance(); - while (pos < src.size() && std::isdigit(static_cast(peek()))) advance(); + if (!is_digit()) error("invalid number"); + while (is_digit()) advance(); } // std::from_chars(double) isn't implemented in AppleClang's // libc++ (Xcode 15.x ships only the integral overloads, with @@ -254,14 +270,14 @@ struct Parser { // // Copy the digit run into a small std::string so strtod can // walk a null-terminated buffer without us mutating the - // source. pre-validation by the loop above guarantees the - // substring is well-formed; the only error we have to detect - // here is overflow via ERANGE. + // source. The loops above make the substring JSON-shaped; + // ERANGE still catches overflow, and a full-consume check + // rejects any leftover that a looser libc might leave. std::string num_str(src.data() + start, pos - start); errno = 0; char* end_ptr = nullptr; double val = std::strtod(num_str.c_str(), &end_ptr); - if (end_ptr == num_str.c_str() || errno == ERANGE) { + if (end_ptr != num_str.c_str() + num_str.size() || errno == ERANGE) { error("invalid number"); } return jnum(val); diff --git a/tests/test_json_numbers.cpp b/tests/test_json_numbers.cpp new file mode 100644 index 00000000..5e8a2387 --- /dev/null +++ b/tests/test_json_numbers.cpp @@ -0,0 +1,74 @@ +// tests/test_json_numbers.cpp — RFC 8259 number tokenizer. +// +// Distinct from #342 (UTF-16 surrogate pairs in parse_string). This +// pins frac/exp completeness: incomplete tokens like `1.` / `1e` were +// accepted because strtod parses a prefix of the tokenizer's capture. + +#define DOCTEST_CONFIG_IMPLEMENT_WITH_MAIN +#include "doctest.h" + +#include "json.h" + +#include +#include + +using namespace arbiter; + +namespace { + +double parse_num(const char* s) { + auto v = json_parse(s); + REQUIRE(v); + REQUIRE(v->is_number()); + return v->as_number(); +} + +void require_throws(const char* s) { + CHECK_THROWS_AS(json_parse(s), std::runtime_error); +} + +} // namespace + +TEST_CASE("json_parse accepts well-formed JSON numbers") { + CHECK(parse_num("0") == 0.0); + CHECK(parse_num("-0") == 0.0); + CHECK(parse_num("1") == 1.0); + CHECK(parse_num("-42") == -42.0); + CHECK(parse_num("1.0") == 1.0); + CHECK(parse_num("0.5") == 0.5); + CHECK(parse_num("-0.25") == -0.25); + CHECK(parse_num("1e2") == 100.0); + CHECK(parse_num("1E2") == 100.0); + CHECK(parse_num("1e+2") == 100.0); + CHECK(parse_num("1e-2") == 0.01); + CHECK(parse_num("1.5e1") == 15.0); + CHECK(parse_num("6.02e23") == 6.02e23); +} + +TEST_CASE("json_parse rejects incomplete fraction and exponent") { + require_throws("1."); + require_throws("-1."); + require_throws("1e"); + require_throws("1E"); + require_throws("1e+"); + require_throws("1e-"); + require_throws("1.e2"); + require_throws("1.E+3"); + require_throws("-.5"); +} + +TEST_CASE("json_parse rejects incomplete numbers inside composites") { + require_throws("[1.,2]"); + require_throws("[1e]"); + require_throws("{\"n\":1e}"); + require_throws("{\"n\":1.}"); + // A well-formed neighbour still parses; only the incomplete token fails. + auto ok = json_parse("[1.0,2]"); + REQUIRE(ok); + REQUIRE(ok->is_array()); + CHECK(ok->as_array().size() == 2); +} + +TEST_CASE("json_parse overflow still fails closed") { + require_throws("1e9999"); +}