fix(mcp): refuse a numeric parameter that is not a whole int64 instead of changing it (BUG-4061)

numericInput converted a float64 with int64(x), so limit 1.5 read as 1, -2.7
as -2, and a value outside int64 as an implementation-defined number. A
json.Number that did not parse read as absent, so the parameter was dropped.

- numericInput returns an error for a fraction, a value outside int64 (2^63
  is out), and an unparseable json.Number. intParam names the parameter.
- All 10 caller-input sites refuse with validation_failed: the route mappers,
  item history, attachment list, and the pad_item.list and pad_item.history
  catalog actions. pad_item.list's documented truncation of a fractional
  limit to the default is reversed by ruling; zero still means the default.
- actionItemGet's read of the server's own seq stays lenient (pinned).
- itemExpectedSeqParam refuses a float64 outside int64 and gets the same
  json.Number refusal, rather than dropping the token (an unguarded write).
- Comments note the unreachable float32 paths in formatNumber and
  chatSequence.
- ToolSurfaceVersion 0.103: the parameters are declared "number", so the
  refused inputs were valid under the advertised shape. Goldens move only by
  the version string.

Claude-Session: https://claude.ai/code/session_01Hk4vxsaKDm3E1LvsoHtHeV
This commit is contained in:
xarmian
2026-10-24 13:57:06 +00:00
parent 0ed34ea9f7
commit 54fc028555
12 changed files with 362 additions and 42 deletions
+1 -1
View File
@@ -8,7 +8,7 @@
},
"padToolSurface": {
"tool_surface_stable": true,
"version": "0.102"
"version": "0.103"
}
},
"prompts": {},
+1 -1
View File
@@ -8,7 +8,7 @@
},
"padToolSurface": {
"tool_surface_stable": true,
"version": "0.102"
"version": "0.103"
}
},
"prompts": {},
+1 -1
View File
File diff suppressed because one or more lines are too long
@@ -69,12 +69,14 @@ func TestBUG3704_PadItemListSaysWhenItIsTruncated(t *testing.T) {
}
}
// A fractional limit under 1 takes the default, as zero does (it used to,
// through the CLI's own default; the probe row must not turn it into 1).
func TestBUG3704_PadItemListFractionalLimitTakesTheDefault(t *testing.T) {
// A zero limit takes the default (it used to, through the CLI's own default;
// the probe row must not turn it into 1). A FRACTIONAL limit under 1 used to
// take the default the same way, by truncation; BUG-4061 refuses it instead
// (TestBUG4061_CatalogLimitRefusesAFraction).
func TestBUG3704_PadItemListZeroLimitTakesTheDefault(t *testing.T) {
disp := &listRowsDispatcher{rows: 3}
env := ActionEnv{Doc: liveCmdhelpDoc(t), Workspace: NewWorkspaceState("docapp"), Dispatcher: disp}
if _, err := actionItemList(context.Background(), map[string]any{"limit": float64(0.5)}, env); err != nil {
if _, err := actionItemList(context.Background(), map[string]any{"limit": float64(0)}, env); err != nil {
t.Fatal(err)
}
if !strings.Contains(strings.Join(disp.gotArgs, " "), "--limit 51") {
@@ -0,0 +1,215 @@
package mcp
import (
"context"
"encoding/json"
"net/http"
"strings"
"testing"
"github.com/PerpetualSoftware/pad/internal/models"
"github.com/mark3labs/mcp-go/mcp"
)
// BUG-4061: a number that is not a whole int64 is refused, never changed:
// int64(1.5) is 1, int64(-2.7) is -2, int64(1e19) is implementation-defined,
// and a json.Number that does not parse used to read as absent.
func TestBUG4061_NumericInputRefusesWhatItWouldChange(t *testing.T) {
cases := []struct {
name string
in any
want int64
present bool
wantErr string
}{
{name: "whole float64", in: float64(42), want: 42, present: true},
{name: "negative whole float64", in: float64(-3), want: -3, present: true},
{name: "fraction", in: 1.5, wantErr: "must be a whole number, got 1.5"},
{name: "negative fraction", in: -2.7, wantErr: "must be a whole number, got -2.7"},
{name: "above int64", in: 1e19, wantErr: "is out of range"},
{name: "below int64", in: -1e19, wantErr: "is out of range"},
{name: "2^63 is out of range", in: float64(1 << 63), wantErr: "is out of range"},
{name: "-2^63 is in range", in: -float64(1 << 63), want: -1 << 63, present: true},
{name: "int", in: 7, want: 7, present: true},
{name: "int64", in: int64(8), want: 8, present: true},
{name: "json.Number whole", in: json.Number("42"), want: 42, present: true},
{name: "json.Number fraction", in: json.Number("1.5"), wantErr: "got 1.5"},
{name: "json.Number above int64", in: json.Number("1e19"), wantErr: "got 1e19"},
{name: "nil is absent", in: nil},
{name: "a string is absent, as before", in: "10"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got, present, err := numericInput(tc.in)
if tc.wantErr != "" {
if err == nil || !strings.Contains(err.Error(), tc.wantErr) {
t.Fatalf("numericInput(%#v) = %d, %v, %v; want a refusal containing %q", tc.in, got, present, err, tc.wantErr)
}
if present {
t.Fatal("a refused value reported present")
}
return
}
if err != nil || got != tc.want || present != tc.present {
t.Fatalf("numericInput(%#v) = %d, %v, %v; want %d, %v, nil", tc.in, got, present, err, tc.want, tc.present)
}
})
}
if _, _, err := intParam(map[string]any{"limit": 1.5}, "limit"); err == nil || err.Error() != "limit must be a whole number, got 1.5" {
t.Fatalf("intParam does not name the parameter: %v", err)
}
}
// BUG-4061 through the remote dispatcher: every door that reads a numeric
// parameter refuses a fraction with a validation error naming it, and the
// request never reaches the handler. One row per (door, parameter), covering
// both refusal paths: a route mapper (validationFailedResult in Dispatch) and
// a special dispatch function (its own validationFailedResult).
func TestBUG4061_EveryDispatchDoorRefusesAFractionNamingIt(t *testing.T) {
cases := []struct {
cmd []string
input map[string]any
param string
}{
{[]string{"attachment", "list"}, map[string]any{"limit": 1.5}, "limit"},
{[]string{"attachment", "list"}, map[string]any{"offset": 2.5}, "offset"},
{[]string{"item", "history"}, map[string]any{"ref": "TASK-1", "limit": 1.5}, "limit"},
{[]string{"item", "starred"}, map[string]any{"limit": 1.5}, "limit"},
{[]string{"workspace", "audit-log"}, map[string]any{"days": 1.5}, "days"},
{[]string{"workspace", "audit-log"}, map[string]any{"limit": 1e19}, "limit"},
{[]string{"item", "list"}, map[string]any{"limit": -2.7}, "limit"},
{[]string{"item", "list"}, map[string]any{"offset": 1.5}, "offset"},
}
for _, tc := range cases {
t.Run(strings.Join(tc.cmd, " ")+" "+tc.param, func(t *testing.T) {
var reached string
mux := http.NewServeMux()
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
reached = r.URL.RequestURI()
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{}`))
})
d := &HTTPHandlerDispatcher{Handler: mux, UserResolver: fixedUserResolver(&models.User{ID: "u"})}
input := map[string]any{"workspace": "docapp"}
for k, v := range tc.input {
input[k] = v
}
res, err := d.Dispatch(WithDispatchInput(context.Background(), input), tc.cmd, nil)
if err != nil {
t.Fatal(err)
}
if res == nil || !res.IsError {
t.Fatalf("not refused; the request reached %q", reached)
}
if txt := textOf(res); !strings.Contains(txt, tc.param+" ") || !strings.Contains(txt, string(ErrValidationFailed)) {
t.Fatalf("refusal does not name %q as a validation failure: %s", tc.param, txt)
}
if reached != "" {
t.Fatalf("a refused input reached the handler at %q", reached)
}
})
}
}
// BUG-4061 at the catalog doors: pad_item.list (whose limit used to truncate a
// fraction to the default by design, ruled refuse) and pad_item.history.
func TestBUG4061_CatalogLimitRefusesAFraction(t *testing.T) {
for _, tc := range []struct {
name string
act func(context.Context, map[string]any, ActionEnv) (*CallToolResult, error)
}{
{"pad_item.list", actionItemList},
{"pad_item.history", actionItemHistory},
} {
t.Run(tc.name, func(t *testing.T) {
disp := &fakeDispatcher{}
env := ActionEnv{Doc: liveCmdhelpDoc(t), Workspace: NewWorkspaceState("docapp"), Dispatcher: disp}
res, err := tc.act(context.Background(), map[string]any{"ref": "TASK-1", "limit": 0.5}, env)
if err != nil {
t.Fatal(err)
}
if res == nil || !res.IsError || !strings.Contains(textOf(res), tc.name+": limit must be a whole number, got 0.5") {
t.Fatalf("expected a refusal naming limit, got %#v", res)
}
if len(disp.gotPath) != 0 {
t.Fatalf("a refused limit reached the dispatcher: %v", disp.gotPath)
}
// Zero still means the default: refusing fractions changes nothing else.
if res, err := tc.act(context.Background(), map[string]any{"ref": "TASK-1", "limit": float64(0)}, env); err != nil || res.IsError {
t.Fatalf("limit 0 was refused: %v %s", err, textOf(res))
}
})
}
}
// structuredDispatcher answers every dispatch with a fixed structured body.
type structuredDispatcher struct{ body map[string]any }
func (s structuredDispatcher) Dispatch(context.Context, []string, []string) (*CallToolResult, error) {
return &mcp.CallToolResult{StructuredContent: s.body}, nil
}
// BUG-4061 site 5: the lines view reads the SERVER's seq, not caller input,
// so it stays lenient (ruled not applicable): absent, nil or malformed leaves
// Seq zero rather than failing the read.
func TestBUG4061_LinesViewReadsTheServerSeqLeniently(t *testing.T) {
for _, tc := range []struct {
name string
seq any
want int64
}{
{"absent", nil, 0},
{"whole", float64(17), 17},
{"fraction", 1.5, 0},
} {
t.Run(tc.name, func(t *testing.T) {
body := map[string]any{"ref": "TASK-1", "content": "a\nb\n"}
if tc.seq != nil {
body["seq"] = tc.seq
}
env := ActionEnv{Doc: liveCmdhelpDoc(t), Workspace: NewWorkspaceState("docapp"), Dispatcher: structuredDispatcher{body: body}}
res, err := actionItemGet(context.Background(), map[string]any{"ref": "TASK-1", "lines": "1-2"}, env)
if err != nil || res.IsError {
t.Fatalf("lines read failed: %v %s", err, textOf(res))
}
b, _ := json.Marshal(res.StructuredContent)
var view struct {
Seq int64 `json:"seq"`
}
_ = json.Unmarshal(b, &view)
if view.Seq != tc.want {
t.Fatalf("seq = %d, want %d", view.Seq, tc.want)
}
})
}
}
// BUG-4061 B: expected_seq outside int64, or as a json.Number, gets the same
// refusal as a fraction rather than an implementation-defined token or an
// unguarded write.
func TestBUG4061_ExpectedSeqRangeAndJSONNumber(t *testing.T) {
for _, tc := range []struct {
name string
v any
want int64
wantErr string
}{
{"above int64", 1e19, 0, "expected_seq is out of range"},
{"json.Number whole", json.Number("42"), 42, ""},
{"json.Number fraction", json.Number("42.7"), 0, "expected_seq must be a whole number in range, got 42.7"},
{"json.Number above int64", json.Number("1e19"), 0, "expected_seq must be a whole number in range"},
} {
t.Run(tc.name, func(t *testing.T) {
got, present, err := itemExpectedSeqParam(map[string]any{"expected_seq": tc.v})
if tc.wantErr != "" {
if err == nil || !strings.Contains(err.Error(), tc.wantErr) || present {
t.Fatalf("got %d, %v, %v; want a refusal containing %q", got, present, err, tc.wantErr)
}
return
}
if err != nil || !present || got != tc.want {
t.Fatalf("got %d, %v, %v; want %d present", got, present, err, tc.want)
}
})
}
}
+16 -5
View File
@@ -798,9 +798,14 @@ func actionItemList(ctx context.Context, input map[string]any, env ActionEnv) (*
out[k] = v
}
// Whole rows: a fractional limit under 1 takes the default, as zero does.
// A limit of zero or less takes the default. A fractional one is refused,
// not truncated (BUG-4061): 0.5 used to become 0, and so the default.
limit := mcpItemListDefaultLimit
if n, ok := numericInput(input["limit"]); ok && int(n) > 0 {
n, ok, err := intParam(input, "limit")
if err != nil {
return errStructured("pad_item.list", err), nil
}
if ok && int(n) > 0 {
limit = int(n)
}
if limit > mcpItemListMaxLimit {
@@ -892,7 +897,11 @@ func actionItemHistory(ctx context.Context, input map[string]any, env ActionEnv)
}
limit := mcpItemHistoryDefaultLimit
if n, ok := numericInput(input["limit"]); ok && n > 0 {
n, ok, err := intParam(input, "limit")
if err != nil {
return errStructured("pad_item.history", err), nil
}
if ok && n > 0 {
limit = int(n)
}
if limit > mcpItemHistoryMaxLimit {
@@ -1022,8 +1031,10 @@ func actionItemGet(ctx context.Context, input map[string]any, env ActionEnv) (*C
lines, from, to, total := bodypatch.SliceBodyLines(content, start, end)
view := bodypatch.BodyLines{Start: from, End: to, TotalLines: total, Lines: lines}
view.Ref, _ = m["ref"].(string)
if n, ok := numericInput(m["seq"]); ok {
view.Seq = int64(n)
// The server's own seq, not caller input: read leniently, so an absent
// or malformed one leaves Seq zero (BUG-4061 ruled it out of scope).
if n, ok, _ := numericInput(m["seq"]); ok {
view.Seq = n
}
view.ContentState, _ = m["content_state"].(string)
text, _ := json.Marshal(view)
+13 -4
View File
@@ -4,7 +4,6 @@ import (
"context"
"encoding/json"
"fmt"
"math"
"net/http"
"net/http/httptest"
"net/url"
@@ -817,12 +816,22 @@ func (d *HTTPHandlerDispatcher) maybeInjectWorkspace(
// A free function so it can be driven directly: the dispatcher method around it
// needs a live handler chain.
func itemExpectedSeqParam(input map[string]any) (int64, bool, error) {
// BUG-4061: a float64 outside int64 is refused too (int64 of it is
// implementation-defined), and a json.Number gets the same refusal rather
// than reading as absent, which would make the write unguarded.
switch v := input["expected_seq"].(type) {
case float64:
if v != math.Trunc(v) {
return 0, false, fmt.Errorf("expected_seq must be a whole number, got %v", v)
n, err := wholeInt64(v)
if err != nil {
return 0, false, fmt.Errorf("expected_seq %w", err)
}
return int64(v), true, nil
return n, true, nil
case json.Number:
n, err := v.Int64()
if err != nil {
return 0, false, fmt.Errorf("expected_seq must be a whole number in range, got %s", v.String())
}
return n, true, nil
case int64:
return v, true, nil
case int:
+8 -5
View File
@@ -65,11 +65,14 @@ func (d *HTTPHandlerDispatcher) dispatchAttachmentList(
if s, _ := input["sort"].(string); s != "" {
q.Set("sort", s)
}
if n, ok := numericInput(input["limit"]); ok && n > 0 {
q.Set("limit", strconv.FormatInt(n, 10))
}
if n, ok := numericInput(input["offset"]); ok && n > 0 {
q.Set("offset", strconv.FormatInt(n, 10))
for _, key := range []string{"limit", "offset"} {
n, ok, err := intParam(input, key)
if err != nil {
return validationFailedResult(cmdKey, err.Error(), "Pass "+key+" as a whole number."), nil
}
if ok && n > 0 {
q.Set(key, strconv.FormatInt(n, 10))
}
}
switch {
case attached:
+5
View File
@@ -110,6 +110,11 @@ func chatSequence(input map[string]any, name string, min int64) (int64, error) {
}
n = int64(x)
} else {
// Every other type: json.Number and the native integers parse here.
// A float32 would not (fmt.Sprint(float32(1e6)) is "1e+06"), but none
// reaches this today, since the JSON transports deliver only float64
// (BUG-4061 note). A float32 producer would route through the same
// integral and range check as float64 above.
n, err = strconv.ParseInt(fmt.Sprint(v), 10, 64)
}
if err != nil || n < min {
+80 -20
View File
@@ -6,6 +6,7 @@ import (
"errors"
"fmt"
"io"
"math"
"net/http"
"net/http/httptest"
"net/url"
@@ -797,7 +798,11 @@ func mapItemStarred(input map[string]any) (string, string, []byte, error) {
q.Set("include_terminal", "true")
}
// actionItemStarred always sets it, one past the MCP cap (BUG-3790).
if n, ok := numericInput(input["limit"]); ok && int(n) > 0 {
n, ok, err := intParam(input, "limit")
if err != nil {
return "", "", nil, err
}
if ok && int(n) > 0 {
q.Set("limit", strconv.Itoa(int(n)))
}
urlPath := "/api/v1/workspaces/" + url.PathEscape(workspace) + "/starred"
@@ -1194,11 +1199,19 @@ func mapWorkspaceAuditLog(input map[string]any) (string, string, []byte, error)
if s, _ := input["actor"].(string); s != "" {
q.Set("actor", s)
}
if n, ok := numericInput(input["days"]); ok && n > 0 {
q.Set("days", strconv.FormatInt(n, 10))
days, ok, err := intParam(input, "days")
if err != nil {
return "", "", nil, err
}
if n, ok := numericInput(input["limit"]); ok && n > 0 {
q.Set("limit", strconv.FormatInt(n, 10))
if ok && days > 0 {
q.Set("days", strconv.FormatInt(days, 10))
}
limit, ok, err := intParam(input, "limit")
if err != nil {
return "", "", nil, err
}
if ok && limit > 0 {
q.Set("limit", strconv.FormatInt(limit, 10))
}
urlPath := "/api/v1/audit-log"
if encoded := q.Encode(); encoded != "" {
@@ -1409,11 +1422,19 @@ func mapItemList(input map[string]any) (string, string, []byte, error) {
}
// Numeric filters.
if n, ok := numericInput(input["limit"]); ok && n > 0 {
values.Set("limit", strconv.FormatInt(n, 10))
limit, ok, err := intParam(input, "limit")
if err != nil {
return "", "", nil, err
}
if n, ok := numericInput(input["offset"]); ok && n > 0 {
values.Set("offset", strconv.FormatInt(n, 10))
if ok && limit > 0 {
values.Set("limit", strconv.FormatInt(limit, 10))
}
offset, ok, err := intParam(input, "offset")
if err != nil {
return "", "", nil, err
}
if ok && offset > 0 {
values.Set("offset", strconv.FormatInt(offset, 10))
}
if b, _ := input["all"].(bool); b {
@@ -1604,8 +1625,12 @@ func (d *HTTPHandlerDispatcher) dispatchItemHistory(
urlPath := "/api/v1/workspaces/" + url.PathEscape(workspace) + "/items/" + url.PathEscape(ref) + "/versions"
q := url.Values{}
if n, ok := numericInput(input["limit"]); ok && n > 0 {
q.Set("limit", strconv.FormatInt(n, 10))
limit, ok, err := intParam(input, "limit")
if err != nil {
return validationFailedResult(cmdKey, err.Error(), "Pass limit as a whole number."), nil
}
if ok && limit > 0 {
q.Set("limit", strconv.FormatInt(limit, 10))
}
// Ask the server to skip reverse-patch resolution whenever the bodies are
// about to be thrown away below. Without this the projection to
@@ -1670,26 +1695,61 @@ func (d *HTTPHandlerDispatcher) dispatchItemHistory(
// numericInput pulls an int64 out of a JSON-typed input value. JSON
// decoders deliver numbers as float64 (or json.Number when
// UseNumber()); we accept both. Returns (0, false) for nil or
// UseNumber()); we accept both. Returns (0, false, nil) for nil or
// non-numeric inputs so callers can short-circuit.
func numericInput(v any) (int64, bool) {
//
// BUG-4061: a number that is not a whole int64 is REFUSED, never changed.
// int64(1.5) is 1 and int64(-2.7) is -2, and a float64 outside int64 converts
// to an implementation-defined value, so the caller's number used to be
// silently replaced by a different one. A json.Number that does not parse as
// an int64 used to read as absent, so the parameter was silently dropped.
// Both are now an error, and callers name the parameter (intParam).
func numericInput(v any) (int64, bool, error) {
switch x := v.(type) {
case nil:
return 0, false
return 0, false, nil
case float64:
return int64(x), true
n, err := wholeInt64(x)
if err != nil {
return 0, false, err
}
return n, true, nil
case int:
return int64(x), true
return int64(x), true, nil
case int64:
return x, true
return x, true, nil
case json.Number:
n, err := x.Int64()
if err != nil {
return 0, false
return 0, false, fmt.Errorf("must be a whole number in range, got %s", x.String())
}
return n, true
return n, true, nil
}
return 0, false
return 0, false, nil
}
// wholeInt64 converts a float64 that is exactly an int64, and refuses any
// other: a fraction, or a value outside int64. 2^63 itself is out of range
// (MaxInt64 is 2^63-1, and float64(MaxInt64) rounds up to 2^63). NaN fails
// the Trunc comparison; ±Inf fails the range check.
func wholeInt64(x float64) (int64, error) {
if x != math.Trunc(x) {
return 0, fmt.Errorf("must be a whole number, got %v", x)
}
if x >= 1<<63 || x < -(1<<63) {
return 0, fmt.Errorf("is out of range, got %v", x)
}
return int64(x), nil
}
// intParam is numericInput on input[key], with the parameter named in the
// error (BUG-4061).
func intParam(input map[string]any, key string) (int64, bool, error) {
n, ok, err := numericInput(input[key])
if err != nil {
return 0, false, fmt.Errorf("%s %w", key, err)
}
return n, ok, nil
}
// mapItemMove dispatches `pad item move <ref> <target-collection>`.
+4
View File
@@ -15,6 +15,10 @@ func formatNumber(v any) (string, bool) {
case float64:
x = n
case float32:
// UNREACHABLE today (BUG-4061 note): the JSON transports deliver only
// float64, and nothing in internal/mcp or cmd/pad passes a float32.
// Widening prints float32(0.1) as 0.10000000149011612 below; a float32
// producer would want strconv.FormatFloat(float64(n), 'f', -1, 32).
x = float64(n)
case int:
return strconv.Itoa(n), true
+12 -1
View File
@@ -2225,7 +2225,18 @@ const CmdhelpVersion = "0.1"
// mark failures preserve show/send success. Replies mark through reply_to. No direction.
// Existing descriptions were reworded; examples/reasons moved to docs/mcp.md
// without dropping any rule, caveat, refusal or ref.
const ToolSurfaceVersion = "0.102"
//
// v0.103 — BUG-4061: a numeric parameter that is not a whole int64 is REFUSED
// (validation_failed, naming the parameter) instead of silently changed. A
// fraction used to truncate (limit 1.5 read as 1, limit 0.5 as the default), a
// value outside int64 converted to an implementation-defined one, and a
// json.Number that did not parse was dropped as absent. Covers limit, offset
// and days on every remote door, pad_item.list and pad_item.history's limit,
// and expected_seq (range and json.Number; fractions were already refused).
// The schemas declare these as "number", so the refused inputs were valid
// under the advertised shape: the bump is owed by the behaviour, not by a
// schema change (the precedent is the multi_relation DSL refusal).
const ToolSurfaceVersion = "0.103"
// MetaVersionURI is the canonical URI of the queryable version document.
// Lives outside the pad://workspace/{ws}/... namespace because it's a