Intelligence

The Agent Called the Tool. A Program Stored Earlier Ran Inside the Gateway.

CVE-2026-18486, published 4 September 2026 against IBM ContextForge MCP Gateway through v1.0.7, describes a jq program a REST tool carries in its own jsonpath_filter field, supplied by whoever registered or updated that tool, and executed against every later invocation's response by whoever the gateway later authorizes to call it. Moona Intelligence independently read the merged fix, pull request 6205, and the exact source it changed, and confirms the pre fix filter ran with no restriction on jq's own environment, host state, logging or module loading builtins, inside the gateway's own process, reachable through both a REST tool call and an MCP passthrough call. The shipped fix, in release v1.0.8, validates the filter at write time, revalidates it at invocation time so a filter stored before the fix cannot bypass it, and runs it in a forked worker whose process environment is cleared, on Linux only.

Event analysed: . This analysis was published on 5 September 2026.

In IBM ContextForge MCP Gateway, could a jq program supplied when a REST tool was registered read the gateway's own process environment when a different caller later invoked that same tool, and whose decision authorized that program to run at all?

Yes, before the fix shipped in release v1.0.8. GitHub Security Advisory GHSA-2hj7-884r-x6jr formally assigns CVE-2026-18486 on 4 September 2026 against IBM ContextForge MCP Gateway, the MCP Context Forge project, through version 1.0.7, CVSS 3.1 base score 8.8, High, CWE-200, exposure of sensitive information to an unauthorized actor. Moona Intelligence read the affected source directly rather than relying on the advisory's own summary. ContextForge's Tool model, in mcpgateway/db.py, carries a jsonpath_filter text field on every REST tool. Setting or changing it requires a caller whose token carries the tools.create or tools.update permission, enforced by a named require_permission decorator on the tool creation and update routes in mcpgateway/main.py, read directly. Every later invocation of that same tool, by any caller whose token carries the separate tools.execute permission, whether through the ordinary REST tool call route or through the gateway's own MCP passthrough route to a federated server, both confirmed directly by reading tool_service.py's own two call sites, runs that stored filter against the response through a function named extract_using_jq. Before the fix, that function ran the filter with no restriction on which jq builtins it could call. jq itself exposes env and the special variable $ENV to read the process environment, input and inputs to read further data the invoking process supplies, input_filename, input_line_number and $__loc__ to read host state, debug and stderr to write to the gateway's own error stream, and include, import and modulemeta to load further filesystem modules. Moona Intelligence confirms directly, from the merged fix's own new denylist in mcpgateway/utils/jq_guard.py, that these are exactly the builtins the fix now blocks, and states no broader claim about jq's own surface than that denylist supports. The consequence is a program one principal wrote, using a permission scoped to configuring a tool, executing inside the gateway's own process every time a different principal, holding only the separate permission to invoke that tool, calls it afterward. Pull request 6205, merged 14 August 2026 as commit 14dcfecb578a80404dc8c1fd1d66cd3165e68753 and shipped in release v1.0.8 on 18 August 2026, closes this by validating the filter against that same denylist when it is written, through a field validator on both the tool creation and tool update schemas, and again inside extract_using_jq itself at invocation time, so a filter already stored before the fix cannot bypass the new policy simply by having been written earlier. An accepted filter now runs inside a forked worker process whose own environment is cleared before jq compiles anything, bounded by a configurable wall clock timeout defaulting to two seconds, with the worker pool rebuilt automatically if a worker terminates abnormally rather than the gateway falling back to evaluating the filter unsandboxed. A refused or timed out filter returns a text result stating the filter was refused or exceeded its time limit, read directly from tool_service.py, rather than silently returning as though the tool call had succeeded normally. ContextForge's own documentation, read directly, states this sandboxed path depends on the fork start method and is Linux only; on any other platform it is silently inactive regardless of configuration, and a filter runs in process with no environment clearing and no timeout, with the static denylist named as the only protection there. Moona Intelligence confirms directly that the same denylist, unchanged, is present in the fix's own release v1.0.8, in the following release v1.0.9, and on the project's current default branch, so the mitigation has not been rolled back since it shipped. The fix's own documentation separately advises rotating JWT_SECRET_KEY, AUTH_ENCRYPTION_SECRET, the credentials embedded in DATABASE_URL and REDIS_URL, and BASIC_AUTH_PASSWORD for any deployment that ran a build before the change, which this record treats as guidance about what the exposure could have reached rather than a claim that any specific credential was demonstrably taken from a real deployment. This record found no statement of active or known exploitation in the advisory or in the fix's own materials.

Read ContextForge's own source directly and the finding is not that a REST tool's response filter is a dangerous idea. Reshaping a response before an agent sees it is an ordinary, useful feature. The finding is narrower: the field that carries that reshaping instruction is not a template or a lookup key. It is a jq program, and until the fix, nothing stopped that program from reaching past the response it was handed and into the gateway's own process state, on behalf of whichever caller happened to invoke the tool next, whoever that caller was.

What the advisory and this record's own reading of the source establish

GitHub Security Advisory GHSA-2hj7-884r-x6jr states its own summary plainly: IBM ContextForge MCP Gateway through v1.0.7 could allow a remote authenticated attacker to obtain sensitive credentials and escalate privileges due to improper validation of jq filters. Moona Intelligence did not stop at that summary. This record fetched the merged pull request that fixes it, pull request 6205, and downloaded the exact source it changed, at the v1.0.8 tag where the fix first shipped, at the following v1.0.9 tag, and at the project's current default branch, to confirm the mechanism directly rather than restating the advisory's own account of it.

What this record independently confirmed by reading the source rather than a description of it: mcpgateway/db.py's Tool model carries a jsonpath_filter text field on every REST tool. mcpgateway/main.py gates creating or updating a tool behind a require_permission decorator naming tools.create or tools.update. mcpgateway/services/tool_service.py calls a function named extract_using_jq against that stored filter from exactly two places, once inside the REST tool response handling path and once inside the MCP gateway's own passthrough path to a federated server, both reached from a single invoke_tool method that three separate route surfaces call, each gated behind a separate tools.execute permission check. mcpgateway/utils/jq_guard.py, added by the fix, denies exactly eleven jq tokens: env, $ENV, input, inputs, input_filename, input_line_number, $__loc__, debug, stderr, include, import and modulemeta. mcpgateway/utils/jq_runner.py, also added by the fix, runs an accepted filter in a forked worker process, clearing that worker's own environment before jq compiles anything, bounded by a wall clock timeout, and rebuilding its worker pool if one terminates abnormally.

Two permissions, one program, no independent check between them

ContextForge's own RBAC middleware, read directly, names tools.create, tools.update and tools.execute as separate permission strings, checked by separate decorators on separate routes. That separation is meaningful on its own terms: an operator can grant a caller the authority to invoke tools without granting it the authority to define what those tools do, and ContextForge's own route decorators enforce exactly that split. What the pre fix code did not do is carry that split through to the jsonpath_filter field itself. A caller holding only tools.execute, authorized to invoke a named tool and nothing more, triggered extract_using_jq's evaluation of a jq program that some other caller, holding tools.create or tools.update, had written at registration time or at any later edit. Nothing in the pre fix invocation path asked whether the invoking caller's own authority extended to running that specific program, because the program was not treated as a decision requiring authority at all. It was treated as configuration, read and evaluated the same way the gateway would read any other field on the tool it was told to call.

What the stored program could reach before the fix

jq is a general purpose filter language, and several of its own builtins exist specifically to read the state of the process running it. env and the special variable $ENV expose that process's own environment. input and inputs read further data the invoking process supplies beyond the one value already handed to the filter. input_filename, input_line_number and $__loc__ expose further host state about what is currently being processed. debug and stderr write to the process's own error stream. include, import and modulemeta load further jq modules from the filesystem. Moona Intelligence states this list because the fix's own denylist in jq_guard.py names exactly these eleven tokens, confirmed directly from the file itself, and does not extend the claim to a broader inventory of jq's own surface than that denylist supports. Before the fix, none of them were restricted. A jsonpath_filter field that named env directly, run inside extract_using_jq's own unrestricted pre fix evaluation, read the gateway process's own environment, the same process holding whatever secrets an operator configured it with, and returned whatever that read produced as the tool's own response content to the calling agent.

This record keeps two facts distinct rather than collapsing them into one claim. The first is the mechanism itself: a stored jq program, authored under one permission, executing with the gateway's own process authority when triggered under a separate, narrower permission, confirmed directly by this record's own reading of the pre fix evaluation path and the fix's own denylist. The second is what any specific deployment's environment actually held and whether it was actually read by a real attacker. Neither the advisory nor the fix's own materials state that a specific credential was demonstrably exfiltrated from a production deployment, and this record does not claim that. Capability to read the process environment is what this record establishes; observed theft in a real deployment is not something this record found stated anywhere.

Credential rotation is operator guidance, not a shipped control

The fix's own documentation, in docs/docs/manage/securing.md, tells an operator who ran a build released before the change to treat JWT_SECRET_KEY, AUTH_ENCRYPTION_SECRET, and the credentials embedded in DATABASE_URL and REDIS_URL, plus BASIC_AUTH_PASSWORD, as disclosed and to rotate them. This record treats that sentence as exactly what it is: a recommendation an operator must act on themselves, naming the specific secrets a gateway process's own environment plausibly held and that an unrestricted env read could therefore have reached. It is not a control the fix itself enforces, and rotating those secrets is not a step ContextForge's own upgrade path performs automatically. An operator who upgrades to v1.0.8 and does not separately rotate these values remains exposed to whatever a pre fix filter already read, if one existed and was already exploited, a possibility this record cannot rule out and does not claim happened.

The fix, verified directly against the source it changed

Pull request 6205, titled by its own author fix security sandbox jq filter execution for tool jsonpath_filter, was merged 14 August 2026 as commit 14dcfecb578a80404dc8c1fd1d66cd3165e68753 and shipped in release v1.0.8 on 18 August 2026. Moona Intelligence applied no patch and ran no reproduction of its own beyond reading the exact merged source at each tag, since the fix is already shipped and the affected mechanism is confirmed by the denylist's own presence. Four properties this record confirms directly, each from the source itself rather than from the pull request's own description of it:

Write time enforcement. mcpgateway/schemas.py attaches a field validator to jsonpath_filter on both the ToolCreate and ToolUpdate schemas, calling the same assert_safe_jq_filter function the invocation path uses, so a filter naming a denied token is rejected when a caller holding tools.create or tools.update tries to save it.

Invocation time revalidation. extract_using_jq itself calls assert_safe_jq_filter again, immediately before running the filter, independent of whatever validation ran when the filter was first saved. This record reads this as the specific property that stops a row already stored before the fix shipped from bypassing the new policy: the check runs against the row's current content every time the tool is called, not only once at the moment it was written.

Capability attenuation through a cleared worker. jq_runner.py builds a process pool using the fork start method, and its own worker initializer clears the worker's environment before any jq filter compiles inside it. A filter that survives the denylist and still somehow reached toward the process environment would find that specific worker's own environment already empty.

Bounded execution and refusal semantics. A configurable timeout, defaulting to two seconds, bounds how long a filter may run; a worker that terminates abnormally causes the pool to be rebuilt rather than the gateway falling back to running the next filter unsandboxed; and a refused or timed out filter returns a text result naming the refusal or the timeout, read directly from tool_service.py, rather than a result indistinguishable from the tool having simply succeeded.

The platform limitation this record preserves rather than rounds away

ContextForge's own documentation states the sandboxed worker path depends on the fork start method, which it describes as unsafe on Darwin, so subprocess mode is silently inactive on any non Linux platform regardless of configuration. There, the documentation states directly, filters run in process with no environment clearing and no time limit, and the static denylist is the only protection. This record states that limitation exactly as ContextForge's own documentation states it, rather than describing the shipped fix as sandboxing jq filter execution everywhere. On Linux, the write time check, the invocation time recheck and the cleared, bounded worker all apply together. Off Linux, only the denylist does.

Shipped and, as of this record, still shipping

Release v1.0.8, published 18 August 2026, lists pull request 6205 by name under its own security fixes. Moona Intelligence confirms directly that the identical denylist in jq_guard.py is unchanged in the following release, v1.0.9, published 1 September 2026, and on the project's current default branch as of this record's own retrieval, so the mitigation has not been reverted or weakened since it shipped. Release 6268, the pull request that assembled v1.0.8's own changelog, is a version and documentation consolidation covering twenty nine merged pull requests; it does not itself carry the credential rotation guidance this record quotes above, which lives in pull request 6205's own documentation change instead, and it names no CVE or PSIRT reference for this specific vulnerability.

The Authority Provenance ledger

Configuration principal. Whichever caller's token carries the tools.create or tools.update permission, the principal who writes or edits the jsonpath_filter field on a REST tool.

Execution principal. Whichever caller's token carries the separate tools.execute permission, the principal whose invocation of the same tool triggers extract_using_jq against the configuration principal's own stored filter, whether that caller is the same person or an entirely different one.

Executing actor. ContextForge's own extract_using_jq function, running inside the gateway's own process, called from both the REST tool response path and the MCP gateway's own passthrough path to a federated server.

Intended mandate. A caller holding tools.execute is meant to receive that tool's response, reshaped by whatever the configuration principal intended the filter to do to it. It is not, on this record's own reading of the pre fix code, a mandate that extends to whatever else a jq program can be made to do once it is treated as a program rather than as a shaping instruction.

Limit that failed. No check, before the fix, asked whether the stored filter's own content stayed within what a response transformation requires, confirmed directly by the absence of any denylist, validator or sandboxed worker in the pre fix evaluation path this record read.

Inherited assumption. That a field attached to a tool's own definition is inert configuration once an authorized caller has been allowed to invoke that tool, rather than a program whose own execution is a further, separately authorizable event.

Approval. Tool creation and update each require their own named permission, confirmed directly. Neither permission decision independently considered what the stored filter's own content could do once evaluated.

Challenge. None available to the execution principal in the pre fix path; a caller authorized only to invoke the tool had no visibility into, and no ability to constrain, what the configuration principal's own filter would do once triggered.

Revocation or modification. The fix itself is the recovery path: write time validation, confirmed directly in schemas.py, and invocation time revalidation, confirmed directly inside extract_using_jq, together mean a dangerous filter already stored before the fix is rejected the next time it runs, not only the next time it is saved.

Recovery path. Rotating JWT_SECRET_KEY, AUTH_ENCRYPTION_SECRET, the credentials in DATABASE_URL and REDIS_URL, and BASIC_AUTH_PASSWORD, exactly as the fix's own documentation advises for any deployment that ran a build before the change; an operator action, not a step the upgrade itself performs.

Exploitation. Unknown. This record found no statement in the advisory or the fix's own materials that this was exploited against a real deployment, and does not claim it either way.

Provenance evidence quality. Strong for the mechanism itself: this record fetched the merged pull request directly, downloaded and read the exact affected and fixed source at the v1.0.8 tag, confirmed the identical fix is unchanged at the v1.0.9 tag and on the project's current default branch, and independently fetched the advisory itself rather than relying only on a description of it. Weaker for the CVE Program's own formal assignment chain: cve.org, nvd.nist.gov and cveawg.mitre.org were all blocked to direct fetch in this session, so the CVSS score, vector and CWE above are read from the GitHub Advisory Database's own mirror of the record rather than from the CVE Program's own page, and this record does not independently confirm which body served as the assigning CNA. The pull request's own text names an internal reference, PSIRT REF 3874125 and ADV0318908, which this record quotes as stated rather than as independently confirmed against an IBM Support page this session could not reach.

Where this sits in the pattern

Moona Intelligence's own wider thesis, that execution authority has to be evaluated against the action a system actually takes rather than assumed from an earlier, adjacent decision, extends here to a boundary the gateway's own permission model already draws and does not carry all the way through: tools.create and tools.update decide who may configure a tool, tools.execute decides who may invoke it, and until the fix, nothing decided whether the specific program the first decision embedded was itself authorized to run under the second one. This record's own configuration principal and execution principal are not one agent delegating to another, the shape this desk's Authority Inheritance coverage already tracks, but the underlying question is the same one asked one layer earlier: when a later actor's authorized action triggers something an earlier actor embedded, whose authority governs what that something may do. Read next to a validator that correctly blocks a dangerous function call in one syntactic position and never reaches the identical call in another, this record's own mechanism is a different shape again: not a check that exists and fails to reach an equivalent case, but a field the pre fix code never treated as needing a check at all. Set beside a separate MCP gateway's own authentication passthrough failure, both records describe a gateway's own intermediary position as the thing that turns a narrow configuration decision into a broader execution one, here through a stored program rather than through a forwarded credential.

What this record does not establish

This record does not claim CVE-2026-18486 was exploited against a real ContextForge deployment, that every ContextForge operator's own environment held a credential worth stealing, that every tool registered on an affected gateway carried a malicious filter, that IBM served or did not serve as the CVE's own assigning CNA, that the shipped fix sandboxes jq execution on every platform ContextForge runs on, or that jq itself is unsafe to use as a response transformation language generally. Where the evidence available to this record does not establish a fact, this record states it as unknown rather than inferring it from the pattern this weakness class already shows elsewhere.

Sources

This analysis interprets third-party reporting, research and announcements. Moona is not the original reporter of the underlying events.

[2]
fix(security): sandbox jq filter execution for tool jsonpath_filter (#6205)
IBM (GitHub Pull Requests, IBM/mcp-context-forge) · 14 August 2026 · Primary source
[3]
fix(security): sandbox jq filter execution for tool jsonpath_filter (14dcfec)
IBM (GitHub, commit, IBM/mcp-context-forge) · 14 August 2026 · Primary source
[4]
Release v1.0.8, IBM/mcp-context-forge
IBM (GitHub Releases, IBM/mcp-context-forge) · 18 August 2026 · Primary source
[5]
mcpgateway/utils/jq_guard.py at v1.0.8
IBM (GitHub, source, IBM/mcp-context-forge) · Primary source
[6]
mcpgateway/utils/jq_runner.py at v1.0.8
IBM (GitHub, source, IBM/mcp-context-forge) · Primary source
[8]
[9]
mcpgateway/main.py at v1.0.8 (tool creation, update and execution permission decorators)
IBM (GitHub, source, IBM/mcp-context-forge) · Primary source
[10]
docs/docs/manage/securing.md at v1.0.8 (jq filter sandbox: platform limitation and credential rotation)
IBM (GitHub, documentation, IBM/mcp-context-forge) · Technical documentation
[11]
mcpgateway/utils/jq_guard.py on the current default branch
IBM (GitHub, source, IBM/mcp-context-forge) · Technical documentation
[12]
Release/1.0.8 (#6268)
IBM (GitHub Pull Requests, IBM/mcp-context-forge) · 17 August 2026 · Technical documentation

Protocol evidence

This record does not assess these architectures. The connection runs through the Risk Registry requirement each one bears on, and these published authority architectures are what the evidence says about that requirement.

Protocol evidence related through AEW-023 A tool's stored response transformation runs with the gateway's own authority

  • Supports requirement

    Agent Control Standard (ACS)

    OWASP GenAI Security Project, originally Zenity

    Requirement The specification does not guarantee complete mediation of every consequential action a host can take

    ACS's own conformance document states directly that the specification does not guarantee complete mediation of every consequential action a host can take, naming a primitive that bypasses its own toolCallRequest hook as invisible to policy. IBM ContextForge's stored jq filter is concrete evidence for a stage this admission does not itself name: a consequential action, evaluating an executable program against gateway process state, occurring after a tool call has already been authorized and mediated, inside a response transformation step neither the tool's own invocation permission nor a hook fired at the moment of the call was written to examine. This entry's own reading of the pre fix evaluation path supports the requirement that mediation be evaluated against every stage capable of consequential effect, not only the moment a tool call itself is authorized.

    This record is the cited evidence for this relationship.

    View protocol evidence

  • Supports requirement

    ARC, Agentic Runtime Control

    Britive

    Requirement Elevated privilege is documented as minted directly in a target system through its own native API and revoked automatically when a task ends, with credential brokering as a separate path for systems that require one

    Britive's own documented model brokers or mints credentials ephemerally and scoped to a task, rather than leaving a standing credential ambiently reachable by whatever process happens to run. IBM ContextForge's pre fix jsonpath_filter is the failure that principle answers, made concrete: a stored program, once triggered, read the gateway's own standing process environment directly, with no brokering step and no scope narrower than the gateway's own full authority in between. The shipped fix's own cleared, sandboxed worker is a narrower, response transformation scoped instance of the same corrective this property already names for a broader class of runtime credential access, supporting the requirement that executed logic reach only a scoped, brokered capability rather than a gateway's own ambient environment.

    This record is the cited evidence for this relationship.

    View protocol evidence

Related Intelligence

All Intelligence Records →