Skip to content

fix(ai,policy-opa): use own-property checks when resolving per-tool approvals#16900

Merged
gr2m merged 2 commits into
mainfrom
dnukumamras/harden-tool-approval-lookup
Jul 8, 2026
Merged

fix(ai,policy-opa): use own-property checks when resolving per-tool approvals#16900
gr2m merged 2 commits into
mainfrom
dnukumamras/harden-tool-approval-lookup

Conversation

@dnukumamras

@dnukumamras dnukumamras commented Jul 8, 2026

Copy link
Copy Markdown
Contributor

What

Tool sets, tool contexts, and per-tool approval maps are indexed by names that come from model output or client-supplied message history. Plain bracket access (tools[name]) resolves names that match inherited object properties (constructor, toString, valueOf, __proto__) to values on Object.prototype instead of undefined, which can slip past the "unknown tool" / "unconfigured approval" guards.

This routes those lookups through a new getOwn helper (own-property check via Object.hasOwn), builds the human-in-the-loop approval-matching maps with a null prototype, and adds the same own-property guards in policy-opa wrapMcpTools and shadow.

Behavior is identical for every real tool set, since tool names are always own properties. The only change is that an inherited-property name now reads as absent.

Flow

Happy path (real tool named weather, unchanged):

  1. Model or client references weather.
  2. getOwn(tools, "weather") finds it as an own property and returns the tool.
  3. Approval, validation, and execution proceed exactly as before.

Unhappy path (name collides with a prototype member, e.g. constructor):

  1. Model or client references constructor, which is not a registered tool.
  2. Before: tools["constructor"] returned Object.prototype.constructor (a function), passed the != null guard, and could be treated as a tool or invoked as an approval callback.
  3. After: getOwn(tools, "constructor") returns undefined, so the name routes to the safe "no such tool" / "unconfigured, apply fallback" branch (NoSuchToolError, denied/user-approval fallback, etc.).

Notes

Covers the approval path (per-tool resolution and replay re-validation), tool-call parsing, execution, streaming callbacks, and UI message conversion/validation. Includes a getOwn unit test plus regression tests in wrap-mcp-tools, shadow, resolve-tool-approval, and collect-tool-approvals.

…pprovals

Tool sets, tool contexts, and per-tool approval maps are indexed by names
that come from model output or client-supplied message history. Plain bracket
access resolves names that match inherited object properties (constructor,
toString, valueOf, __proto__) to values on Object.prototype, which can slip
past the "unknown tool" / "unconfigured approval" guards.

Add a getOwn helper (own-property check) and route tool/context/approval
lookups through it, build the HITL approval-matching maps with a null
prototype, and add own-property guards in policy-opa wrapMcpTools and shadow.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

@vercel vercel Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Additional Suggestion:

refineToolInput map lookup uses plain bracket access instead of getOwn, so a tool named after an Object.prototype member (e.g. toString, valueOf) resolves to the inherited prototype method rather than undefined, silently corrupting or crashing tool-input refinement.

Fix on Vercel

The per-tool `refineToolInput` map was read with plain bracket access, so a
tool named after an Object.prototype member (e.g. `toString`, `valueOf`)
resolved to the inherited method and was invoked as a refiner. Use the
own-property getOwn helper so such names are treated as unconfigured.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

@gr2m gr2m left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verified the fix against VULN-13170. Own-property lookups (Object.hasOwn / hasOwnProperty + null-prototype maps and the shared getOwn helper) correctly close the inherited-property bypass across both wrapMcpTools/shadow and the core approval/tool-call/streaming/UI paths. Tool names such as constructor, toString, and valueOf now resolve to "unconfigured" and receive the configured fallback. Tests cover the regression and CI is green.

@gr2m
gr2m merged commit aad737d into main Jul 8, 2026
49 checks passed
@gr2m
gr2m deleted the dnukumamras/harden-tool-approval-lookup branch July 8, 2026 20:15
gr2m added a commit that referenced this pull request Jul 8, 2026
…cpTools (#16959)

## Background

Follow-up hardening to #16900 (VULN-13170), which closed the
inherited-property
approval bypass in `wrapMcpTools`.

While reviewing that fix I found a separate, still-open gap in the same
helper.
`wrapMcpTools` documents that the resulting approval configuration is
*total*:
every discovered tool is gated by the supplied approval "when it has an
opinion"
or by the `default` otherwise (`user-approval` by default). The
generic-function
form already enforces this — it normalizes a "no opinion" result
(`not-applicable`/`undefined`) to the fallback. The **per-tool map form
did
not**: it only substituted the fallback for missing entries
(`configured ?? fallback`), so a per-tool approval *function* that
returned
`not-applicable`/`undefined` passed straight through. The tool then
resolved to
`not-applicable`, which the core treats as "no approval required", and
it ran
without an approval request — i.e. the per-tool function **failed open**
instead
of falling back to the default.

## Summary

- `packages/policy-opa/src/wrap-mcp-tools.ts`: in the per-tool map form,
wrap
per-tool approval **functions** so a `not-applicable`/`undefined` result
is
forced through the configured fallback, mirroring the generic-function
form.
Functions that return an actual decision are passed through unchanged,
and
their `input`/`options` arguments are forwarded intact. Static per-tool
  statuses that the caller configured explicitly are left as-is.
- `packages/policy-opa/src/wrap-mcp-tools.test.ts`: add tests covering a
per-tool function that returns `not-applicable` (→ fallback), one that
returns
  `undefined` (→ configured default), one that returns a real decision
  (unchanged), and argument forwarding.
- Patch changeset for `@ai-sdk/policy-opa`.

## Manual Verification

Confirmed the before/after behavior on the per-tool map form:

- Before this change, `wrapMcpTools(tools, { search: () =>
'not-applicable' })`
produced an entry that resolved to `not-applicable`, so the tool
executed with
  no approval request (fail open).
- After this change, the same configuration resolves `search` to the
fallback
(`user-approval`, or the configured `default`), so the call pauses for a
  decision (fail closed). A per-tool function returning an explicit
  `approved`/`denied` decision is unaffected.

Also ran the full package suite (node + edge, 76 passing), `pnpm
type-check`,
and `oxlint`/`oxfmt` — all clean.

## Checklist

- [x] All commits are signed (PRs with unsigned commits cannot be
merged)
- [x] Tests have been added / updated (for bug fixes / features)
- [ ] Documentation has been added / updated (for bug fixes / features)
- [x] A _patch_ changeset for relevant packages has been added (for bug
fixes / features - run `pnpm changeset` in the project root)
- [x] I have reviewed this pull request (self-review)

## Future Work

The per-tool map form still passes an **explicit static**
`'not-applicable'` /
`{ type: 'not-applicable' }` entry through unchanged. This PR
deliberately does
not alter that: unlike a function's runtime "no opinion", a static
`not-applicable` is arguably an explicit caller decision ("this tool
needs no
approval"), and overriding it would contradict the "preserves explicit
per-tool
entries" behavior. Worth a follow-up discussion on whether a
security-oriented
default-deny wrapper should also refuse to honor a static
`not-applicable`.

## Related Issues

Follow-up to #16900 (VULN-13170).

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
@github-actions

github-actions Bot commented Jul 9, 2026

Copy link
Copy Markdown
Contributor

🚀 Published in:

Package Version
ai 7.0.19 github npm
@ai-sdk/alibaba 2.0.9 github npm
@ai-sdk/amazon-bedrock 5.0.15 github npm
@ai-sdk/angular 3.0.19 github npm
@ai-sdk/anthropic 4.0.11 github npm
@ai-sdk/anthropic-aws 2.0.3 github npm
@ai-sdk/assemblyai 3.0.7 github npm
@ai-sdk/azure 4.0.10 github npm
@ai-sdk/baseten 2.0.7 github npm
@ai-sdk/black-forest-labs 2.0.7 github npm
@ai-sdk/bytedance 2.0.8 github npm
@ai-sdk/cerebras 3.0.7 github npm
@ai-sdk/cohere 4.0.7 github npm
@ai-sdk/deepgram 3.0.7 github npm
@ai-sdk/deepinfra 3.0.7 github npm
@ai-sdk/deepseek 3.0.7 github npm
@ai-sdk/devtools 1.0.3 github npm
@ai-sdk/elevenlabs 3.0.8 github npm
@ai-sdk/fal 3.0.8 github npm
@ai-sdk/fireworks 3.0.8 github npm
@ai-sdk/gateway 4.0.15 github npm
@ai-sdk/gladia 3.0.7 github npm
@ai-sdk/google 4.0.11 github npm
@ai-sdk/google-vertex 5.0.14 github npm
@ai-sdk/groq 4.0.7 github npm
@ai-sdk/harness 1.0.22 github npm
@ai-sdk/harness-claude-code 1.0.22 github npm
@ai-sdk/harness-codex 1.0.23 github npm
@ai-sdk/harness-deepagents 1.0.21 github npm
@ai-sdk/harness-opencode 1.0.22 github npm
@ai-sdk/harness-pi 1.0.22 github npm
@ai-sdk/huggingface 2.0.7 github npm
@ai-sdk/hume 3.0.7 github npm
@ai-sdk/klingai 4.0.8 github npm
@ai-sdk/langchain 3.0.19 github npm
@ai-sdk/llamaindex 3.0.19 github npm
@ai-sdk/lmnt 3.0.7 github npm
@ai-sdk/luma 3.0.8 github npm
@ai-sdk/mcp 2.0.10 github npm
@ai-sdk/mistral 4.0.8 github npm
@ai-sdk/moonshotai 3.0.9 github npm
@ai-sdk/open-responses 2.0.7 github npm
@ai-sdk/openai 4.0.10 github npm
@ai-sdk/openai-compatible 3.0.7 github npm
@ai-sdk/otel 1.0.19 github npm
@ai-sdk/perplexity 4.0.8 github npm
@ai-sdk/policy-opa 1.0.19 github npm
@ai-sdk/prodia 2.0.8 github npm
@ai-sdk/provider 4.0.3 github npm
@ai-sdk/provider-utils 5.0.7 github npm
@ai-sdk/quiverai 2.0.7 github npm
@ai-sdk/react 4.0.20 github npm
@ai-sdk/replicate 3.0.8 github npm
@ai-sdk/revai 3.0.7 github npm
@ai-sdk/rsc 3.0.19 github npm
@ai-sdk/sandbox-just-bash 1.0.22 github npm
@ai-sdk/sandbox-vercel 1.0.22 github npm
@ai-sdk/svelte 5.0.19 github npm
@ai-sdk/togetherai 3.0.8 github npm
@ai-sdk/tui 1.0.19 github npm
@ai-sdk/valibot 3.0.7 github npm
@ai-sdk/vercel 3.0.7 github npm
@ai-sdk/voyage 2.0.7 github npm
@ai-sdk/vue 4.0.19 github npm
@ai-sdk/workflow 1.0.19 github npm
@ai-sdk/workflow-harness 1.0.22 github npm
@ai-sdk/xai 4.0.10 github npm

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants