Conversation
When a caller passes `config.requestContext.headers` through
`client.execute(code, config)` (signature already supports this via
`Record<string, unknown>`), the in-process handler dropped them on the
floor. Root cause: the executionConfig builder did:
requestContext: {
...requestConfig.requestContext, // .headers present if caller set it
headers: ctx.headers, // overwrites with ctx headers
...
}
The spread placed the caller's headers into `requestContext`, but the
next line replaced the entire `headers` field with `ctx.headers` —
silently losing the caller's payload.
Fix: merge the two header bags. Caller-supplied headers first, then
ctx.headers takes precedence for overlapping keys. Non-conflicting
caller entries (e.g. a governance layer's `X-ATP-<apiGroup>-token`)
now survive; for conflicts, transport-layer session auth still wins
so callers cannot spoof session auth.
Concrete motivating use case: a gateway-layer policy engine forwards
per-provider auth tokens into the sandbox via the documented
`requestContext.headers` entry point. Without this fix, the sandbox
reaches third-party APIs with no Authorization header despite the
caller having supplied one.
Behavioral impact: purely additive. No caller that relied on the old
behavior (silently dropped headers) can observe a regression, because
silently-dropped data has no observable consequence.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
`filterApiGroups(this.apiGroups)` already returns per-tool-filtered
groups — `isToolAllowed` correctly drops functions whose names are in
`blockTools` / not in `allowOnlyTools` / carry a blocked
`operationType` / blocked `sensitivityLevel`. The subsequent loop in
getFilterContext then re-iterated the UNFILTERED `this.apiGroups` and
re-added every function in any allowed group — silently defeating
every operation-level rule for the `explore_api` code path.
Net symptom: passing `toolRules: { allowOnlyTools: ['calendar.events_list'] }`
through the request body (body.toolRules → runInRequestScope) correctly
filtered `api.*` inside the sandbox AND correctly filtered
`search_api`'s output (search iterates the already-filtered groups),
but `explore_api` returned every operation regardless. Per-tool
governance was silently a no-op for explore.
Fix: iterate the already-filtered `allowedGroups` instead of the raw
`this.apiGroups`. One-line change matching the pattern `SearchEngine
.search` already uses. `this.apiGroups` remains the authoritative
enumeration used by `allGroups` / `allowedTypes`; `allowedTools` now
correctly sources from the filtered set.
Tests: existing `search` tests that assert per-tool filtering can be
duplicated for `explore`'s `items` output to lock this in.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Add a server-side parity test asserting ExplorerService.explore and
SearchEngine.search surface the exact same visible operation set under
identical toolRules scenarios:
- no rules → both show every function
- allowOnlyApiGroups → both hide dropped groups identically
- allowOnlyTools → both narrow to the exact per-op allowlist
- blockTools → both drop the blocked ops identically
- empty allowOnlyApiGroups → both unrestricted (documents the
isGroupAllowed contract)
Lets callers move between search and explore without observing
different visible sets — the property the explorer fix was about.
5/5 passing.
Also shortens the inline comments added to getFilterContext and
handleExecute in the earlier two commits — both now 2-3 lines
instead of a paragraph.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
maslo55555
approved these changes
May 5, 2026
| // so both should see every function. Documenting that parity explicitly. | ||
| const s = await parityUnder(rules, () => searchVisible(search)); | ||
| const e = parityUnder(rules, () => exploreVisible(explorer)); | ||
| expect(e).toEqual(s); |
Contributor
There was a problem hiding this comment.
I guess you forgot to add an assertion that all tools exist
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Two isolated server-side fixes to ATP's handler surface. Both are bug fixes; neither introduces a new API or changes existing contracts for callers that were working.
1.
handleExecutemerges caller-suppliedrequestContext.headerspackages/server/src/handlers/execute.handler.tsWhen a caller passes
config.requestContext.headersthroughclient.execute(code, config)(already a typed entry point viaRecord<string, unknown>), the in-process handler silently dropped them. Root cause: the executionConfig builder did:Fix: merge the two header bags. Caller-supplied headers first, then
ctx.headerstakes precedence on overlapping keys. Non-conflicting caller entries survive; for conflicts, transport-layer session auth still wins so callers cannot spoof session headers.requestContext: { ...requestConfig.requestContext, - headers: ctx.headers, + headers: { + ...(requestConfig.requestContext as { headers?: Record<string, string> } | undefined)?.headers, + ...ctx.headers, + }, path: ctx.path, method: ctx.method, },Impact: purely additive. No caller that relied on the old behavior (silently dropped headers) can observe a regression, because silently-dropped data has no observable consequence. Callers that DID supply
requestContext.headersand expected them to survive now get what they expected.2.
ExplorerService.getFilterContextiteratesallowedGroups, not rawapiGroupspackages/server/src/explorer/index.tsfilterApiGroups(this.apiGroups)already returns per-tool-filtered groups —isToolAllowedcorrectly drops functions whose names are inblockTools/ not inallowOnlyTools/ carry a blockedoperationType/ blockedsensitivityLevel. The subsequent loop ingetFilterContextthen re-iterated the unfilteredthis.apiGroupsand re-added every function in any allowed group:Net symptom: per-tool rules (
allowOnlyTools,blockTools,blockOperationTypes,blockSensitivityLevels) worked correctly for the sandbox'sapi.*namespace AND forSearchEngine.search(which already iteratesallowedGroups— the correct pattern). Butexplore_apireturned every operation regardless. For downstream governance layers that rely onexplore_apito serve a grant-filtered discovery surface, this was a silent no-op.Fix: iterate the already-filtered
allowedGroups. Mirrors the existing pattern inSearchEngine.search.Live verification
Both fixes exercised via an end-to-end smoke in
DaPulse/mcp-tools(ATP POC branchfeat/odedgo/ocana-poc-mcp-gateway). With both applied,atp.explore_apicorrectly filters by per-tool grant rules, andatp.execute_codecorrectly forwards per-provider auth tokens from the caller through to the sandbox's outbound HTTPS calls.Test plan
handleExecutewithconfig.requestContext.headers = { 'x-custom': 'abc' }results in anexecutionConfig.requestContext.headerscontaining'x-custom': 'abc'(plus whatever was inctx.headers).handleExecutewith overlapping keys —config.requestContext.headers = { 'authorization': 'caller' }andctx.headers = { 'authorization': 'session' }— final value is'session'(ctx wins on conflict).ExplorerService.explore('/openapi/calendar/events')withrunInRequestScope({ toolRules: { allowOnlyTools: ['calendar.events_list'] } })→itemscontains exactly['events_list'], not the full events resource list.SearchEngine.searchper-tool filtering tests continue to pass (pattern borrowed from there).🤖 Generated with Claude Code