Skip to content

openapi: additionalProperties:false is declared but never enforced #1382

Description

@Mark-Life

Relates to: #1243


Problem:

An agent passes an argument the tool does not declare, the call returns 200, and nothing tells the agent the argument had no effect. If the agent believed it was applying a filter or an override, the call silently did something other than what was asked. Where the dropped key was the payload, the result is a confusing upstream 400 instead of a cheap local rejection.

buildInputSchema sets additionalProperties: false on every generated tool schema (packages/plugins/openapi/src/sdk/extract.ts:392), but no code path honours it. The invoke path passes the caller's args straight through with an unchecked cast:

// packages/plugins/openapi/src/sdk/backing.ts:687
const result = yield* invokeWithLayer(
  binding,
  (input.args ?? {}) as Record<string, unknown>,
  ...
);

The one pre-flight validator, validateToolArgs, is gated behind approval (packages/core/sdk/src/executor.ts:3605). PR #1243 gated it deliberately, reasoning that a non-pausing call hits the identical failure moments later inside invokeTool. That premise holds for what the validator actually checks, missing path params and bodies, because both routes call buildRequest.

It does not hold for additionalProperties, because buildRequest never checks it either. validateOpenApiBackedToolArgs (backing.ts:730) delegates straight to buildRequest, and buildRequest (invoke.ts:791) only ever reads args by the specific names the operation declares, plus body, bodyBase64, and contentType by literal property access. It never enumerates Object.keys(args). So the constraint is unenforced on both routes, not merely skipped on one: there is no JSON Schema validator anywhere in the plugin's invoke path.

A concrete instance: the Google Search Console analytics operation names its payload body. Passing requestBody, which Google's own client libraries use, drops the payload silently, and Google answers 400 startDate field is required.

Citations are against 738628132. The deployed cloud build's revision is not observable from the client.

Reproduction:

Runnable against the existing harness in packages/plugins/openapi/src/sdk/plugin.test.ts, alongside "invokes getItem with path parameter" at line 643.

it.effect("silently drops an unknown top-level arg instead of rejecting it", () =>
  Effect.scoped(
    Effect.gen(function* () {
      const server = yield* servePluginTestApi();
      const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() }));
      const conn = yield* addOpenApiTestConnection(executor, server, { slug: "test" });

      // getItem's schema declares additionalProperties: false. GET skips approval,
      // so this call never reaches validateToolArgs at all.
      const result = unwrapInvocation(
        yield* executor.execute(conn.address("items.getItem"), {
          itemId: "2",
          doesNotExist: "nope",
        }),
      );

      expect(result.error).toBeNull();
      expect(result.data).toEqual({ id: 2, name: "Gadget" });
    }),
  ),
);

items.createItem from the same file shows the approval-gated route fails the same way: { body: { name: "New item" }, doesNotExist: "nope" } still validates, still succeeds.

Expected vs actual:

Expected: a call carrying a key the schema does not declare is rejected locally, before any HTTP request, naming the unknown argument. This is the clean local rejection PR #1243 established for missing required args.

Actual: the key is dropped in silence, on both the approval-gated and the ungated route.

Proposed solution:

Compare Object.keys(args) against the names the operation declares, its parameters plus body, bodyBase64, contentType, and server where applicable, and fail with a local OpenApiInvocationError naming the unexpected keys. The same mechanism buildRequest already uses for bodyBase64 failures.

Run it inside buildRequest, or immediately before backing.ts:687 calls it. Placing it there means it runs on every invocation, which closes the gap for non-approval tools without a second validateToolArgs call. If a real JSON Schema validator is preferred to a hand-rolled key comparison, run the schema from buildInputSchema through one at both invokeOpenApiBackedTool and validateOpenApiBackedToolArgs.

Worth confirming first whether additionalProperties: false was set speculatively, for a future model function-calling surface, rather than as an active constraint on this plugin. That decides whether enforcement belongs here or in a shared arg-validation layer, since validateToolArgs is a generic plugin hook others could implement.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions