[GH-ISSUE #4055] [Bug] verify_managed_native_boundaries counts delegate (runtime-provided) methods as unresolved native bodies #1603

Closed
opened 2026-10-16 21:07:49 +00:00 by bsakaguchi · 3 comments
Owner

Originally created by @Rinkia on GitHub (Oct 15, 2026).
Original GitHub issue: https://github.com/morluto/rea/issues/4055

Before submitting

  • I searched the open and closed issues for a related report (native_body_unresolved, runtime-provided, delegate, managed/native verification): none found. Related but different: #3351 (module paths), #3201 (missing module identity), #3961 (Mach-O prefixed exports).
  • I removed credentials, tokens, private paths, and sensitive target data.

Area

Evidence, artifacts, or reconstruction

Observed behavior

verify_managed_native_boundaries reports every non-P/Invoke row of native_implementations as native_body_unresolved, with the reason "C++/CLI, ReadyToRun, and native-body mappings require explicit provider-supported bridge evidence". inspect_managed_native_boundaries also lists methods whose MethodImplAttributes code type is Runtime (boundary_kind: "runtime-provided"). That is what every C# delegate's .ctor, Invoke, BeginInvoke and EndInvoke carry. The CLR supplies these at run time, so there's no native body for a bridge to find.

Real assemblies:

Assembly P/Invokes runtime-provided rows native_body_unresolved
LibGit2Sharp 0.31.0 lib/net8.0/LibGit2Sharp.dll 371 356 (89 delegates × 4) 356
LibreHardwareMonitor 0.9.6 LibreHardwareMonitorLib.dll (net472) 97 188 (47 delegates × 4) 188 (inspection count)

In both, every non-P/Invoke row is a delegate method named .ctor, Invoke, BeginInvoke or EndInvoke. Neither assembly is C++/CLI, mixed-mode or ReadyToRun (il_only: true, ready_to_run: false, mixed_mode_or_native_header: false).

Over MCP, native_body_unresolved > 0 is one of the conditions that records a session unknown ("Which managed/native boundaries remain unresolved or contradicted…", verifyManagedNativeBoundaries.ts:85). So an assembly whose P/Invokes all verify still leaves an unresolved boundary unknown if it declares a single delegate. That is source-derived; I ran the CLI only.

Reproduction

Minimal, using the repository's own fixture builder on main @ 337d2e33c (same pattern as #3351). It builds two PE/CLI fixtures that share one P/Invoke. The second adds one method with MethodImplAttributes.Runtime (0x0003) and no body, as a delegate's Invoke has. Each verifies against the same synthetic function Evidence.

npm ci && npm run build
node repro.mjs
repro.mjs (run from the repository root)
import { mkdirSync, writeFileSync } from 'node:fs';
import { spawnSync } from 'node:child_process';
import { buildManagedPeFixture } from './scripts/fixtures/managed/pe.mjs';
import { functionDossier } from './scripts/fixtures/managed/function-dossier.mjs';
import { createEvidence } from './dist/domain/evidence.js';
mkdirSync('sample-home', { recursive: true });
const run = (...args) => {
  const result = spawnSync(process.execPath, ['scripts/rea.mjs', ...args, '--format', 'json', '--full-output'], {
    encoding: 'utf8', env: { ...process.env, HOME: `${process.cwd()}/sample-home` }
  });
  const output = JSON.parse(result.stdout);
  if (result.status !== 0) throw new Error(JSON.stringify(output));
  return output.data;
};
const native = createEvidence(
  { path: 'helper.dll', sha256: '1'.repeat(64), format: 'pe' },
  { id: 'fixture-native', name: 'Fixture native', version: '1' },
  { operation: 'analyze_function', parameters: {}, result: functionDossier('native_call'), rawResult: null }
);
for (const [name, withRuntimeMethod] of [['pinvoke-only', false], ['pinvoke-and-runtime-method', true]]) {
  const methods = [
    { name: 'native_call', flags: 0x2096, implFlags: 0x0080, rva: 0, body: Buffer.alloc(0) },
    ...(withRuntimeMethod ? [{ name: 'Invoke', flags: 0x01c6, implFlags: 0x0003, rva: 0, body: Buffer.alloc(0) }] : []),
  ];
  const path = `${name}.dll`;
  writeFileSync(path, buildManagedPeFixture({ methods, pinvoke: { moduleName: 'helper.dll', importName: 'native_call', mappingFlags: 0x0101 } }));
  const managed = run('inspect-managed-native-boundaries', path);
  const inputPath = `${name}.json`;
  writeFileSync(inputPath, JSON.stringify({ managed_boundaries: managed, native_observations: [native] }));
  const verification = run('verify-managed-native-boundaries', inputPath).normalized_result;
  console.log(JSON.stringify({
    fixture: name,
    implementations: managed.normalized_result.native_implementations.map((i) => `${i.name}:${i.boundary_kind}`),
    summary: verification.summary,
  }));
}

Output:

{"fixture":"pinvoke-only","implementations":["native_call:pinvoke"],"summary":{"verified":1,"inferred":0,"unresolved":0,"contradicted":0,"native_body_unresolved":0}}
{"fixture":"pinvoke-and-runtime-method","implementations":["native_call:pinvoke","Invoke:runtime-provided"],"summary":{"verified":1,"inferred":0,"unresolved":0,"contradicted":0,"native_body_unresolved":1}}

The fixture's type isn't a real System.MulticastDelegate subclass. The classification uses only the method's impl flags (ManagedNativeBoundaryHelpers.ts:377), so a real delegate behaves the same way, as the LibGit2Sharp numbers show.

Real-data reproduction: LibGit2Sharp 0.31.0 from nuget.org (libgit2sharp.0.31.0.nupkg, sha256 02fbec03776c0db706363a715973b91758b0f1b86b72d696434303da8252c890; NuGet SHA-512 verified). Run inspect-managed-native-boundaries on lib/net8.0/LibGit2Sharp.dll (sha256 28b694519a8a09bff6db8420236f9e835ec8b504d68bea76aa60beb5a4c41083), then verify it against any one analyze_function record from LibGit2Sharp.NativeBinaries 2.0.323. Result: verified: 1, unresolved: 370, native_body_unresolved: 356.

Cause

src/domain/managed/managedNativeVerificationMatch.ts:374-376 on main:

const nativeBodyUnresolved = managed.native_implementations.filter(
  ({ boundary_kind: kind }) => kind !== "pinvoke",
).length;

Every boundary kind other than pinvoke counts, runtime-provided included. The inspection already separates runtime-provided from native-body, unmanaged-method and mixed-or-unknown.

Expected behavior

  • native_body_unresolved counts only rows that can have a native body for a bridge to map (native-body, unmanaged-method, and possibly mixed-or-unknown), not runtime-provided.
  • runtime-provided rows are reported separately, e.g. a runtime_provided count with a reason such as "implemented by the CLR at run time; no file-backed native body", or left out of the unresolved summary.
  • The MCP unknown isn't raised by runtime-provided rows alone.

Impact and scope

  • Any assembly that declares a delegate, which covers nearly every non-trivial .NET assembly and especially interop-heavy ones that marshal callbacks. The unresolved count is inflated with rows that can never resolve, and the reason text sends users looking for C++/CLI/ReadyToRun bridge evidence that doesn't apply.
  • Deterministic. Verified on 6.5.0 (LibGit2Sharp, LibreHardwareMonitor) and on main @ 337d2e33c (fixture above).

REA version

rea-agents@6.5.0 and main @ 337d2e33c.

Runtime and platform

Linux x64 Docker (rea image for 6.5.0, node:24-bookworm for main, Node 24.21.0) on Windows 11 / Docker Desktop.

Target and artifact context

Synthetic fixture from scripts/fixtures/managed/pe.mjs; public NuGet packages LibGit2Sharp 0.31.0 (MIT) and LibGit2Sharp.NativeBinaries 2.0.323.

Interface

CLI. MCP shares the workflow; the unknown-recording consequence is source-derived.

Originally created by @Rinkia on GitHub (Oct 15, 2026). Original GitHub issue: https://github.com/morluto/rea/issues/4055 ### Before submitting - [x] I searched the open and closed issues for a related report (`native_body_unresolved`, runtime-provided, delegate, managed/native verification): none found. Related but different: #3351 (module paths), #3201 (missing module identity), #3961 (Mach-O prefixed exports). - [x] I removed credentials, tokens, private paths, and sensitive target data. ### Area Evidence, artifacts, or reconstruction ### Observed behavior `verify_managed_native_boundaries` reports every non-P/Invoke row of `native_implementations` as `native_body_unresolved`, with the reason "C++/CLI, ReadyToRun, and native-body mappings require explicit provider-supported bridge evidence". `inspect_managed_native_boundaries` also lists methods whose `MethodImplAttributes` code type is `Runtime` (`boundary_kind: "runtime-provided"`). That is what every C# delegate's `.ctor`, `Invoke`, `BeginInvoke` and `EndInvoke` carry. The CLR supplies these at run time, so there's no native body for a bridge to find. Real assemblies: | Assembly | P/Invokes | `runtime-provided` rows | `native_body_unresolved` | | --- | --- | --- | --- | | LibGit2Sharp 0.31.0 `lib/net8.0/LibGit2Sharp.dll` | 371 | 356 (89 delegates × 4) | 356 | | LibreHardwareMonitor 0.9.6 `LibreHardwareMonitorLib.dll` (net472) | 97 | 188 (47 delegates × 4) | 188 (inspection count) | In both, every non-P/Invoke row is a delegate method named `.ctor`, `Invoke`, `BeginInvoke` or `EndInvoke`. Neither assembly is C++/CLI, mixed-mode or ReadyToRun (`il_only: true`, `ready_to_run: false`, `mixed_mode_or_native_header: false`). Over MCP, `native_body_unresolved > 0` is one of the conditions that records a session unknown ("Which managed/native boundaries remain unresolved or contradicted…", `verifyManagedNativeBoundaries.ts:85`). So an assembly whose P/Invokes all verify still leaves an unresolved boundary unknown if it declares a single delegate. That is source-derived; I ran the CLI only. ### Reproduction Minimal, using the repository's own fixture builder on `main` @ `337d2e33c` (same pattern as #3351). It builds two PE/CLI fixtures that share one P/Invoke. The second adds one method with `MethodImplAttributes.Runtime` (0x0003) and no body, as a delegate's `Invoke` has. Each verifies against the same synthetic function Evidence. ```sh npm ci && npm run build node repro.mjs ``` <details><summary>repro.mjs (run from the repository root)</summary> ```js import { mkdirSync, writeFileSync } from 'node:fs'; import { spawnSync } from 'node:child_process'; import { buildManagedPeFixture } from './scripts/fixtures/managed/pe.mjs'; import { functionDossier } from './scripts/fixtures/managed/function-dossier.mjs'; import { createEvidence } from './dist/domain/evidence.js'; mkdirSync('sample-home', { recursive: true }); const run = (...args) => { const result = spawnSync(process.execPath, ['scripts/rea.mjs', ...args, '--format', 'json', '--full-output'], { encoding: 'utf8', env: { ...process.env, HOME: `${process.cwd()}/sample-home` } }); const output = JSON.parse(result.stdout); if (result.status !== 0) throw new Error(JSON.stringify(output)); return output.data; }; const native = createEvidence( { path: 'helper.dll', sha256: '1'.repeat(64), format: 'pe' }, { id: 'fixture-native', name: 'Fixture native', version: '1' }, { operation: 'analyze_function', parameters: {}, result: functionDossier('native_call'), rawResult: null } ); for (const [name, withRuntimeMethod] of [['pinvoke-only', false], ['pinvoke-and-runtime-method', true]]) { const methods = [ { name: 'native_call', flags: 0x2096, implFlags: 0x0080, rva: 0, body: Buffer.alloc(0) }, ...(withRuntimeMethod ? [{ name: 'Invoke', flags: 0x01c6, implFlags: 0x0003, rva: 0, body: Buffer.alloc(0) }] : []), ]; const path = `${name}.dll`; writeFileSync(path, buildManagedPeFixture({ methods, pinvoke: { moduleName: 'helper.dll', importName: 'native_call', mappingFlags: 0x0101 } })); const managed = run('inspect-managed-native-boundaries', path); const inputPath = `${name}.json`; writeFileSync(inputPath, JSON.stringify({ managed_boundaries: managed, native_observations: [native] })); const verification = run('verify-managed-native-boundaries', inputPath).normalized_result; console.log(JSON.stringify({ fixture: name, implementations: managed.normalized_result.native_implementations.map((i) => `${i.name}:${i.boundary_kind}`), summary: verification.summary, })); } ``` </details> Output: ```text {"fixture":"pinvoke-only","implementations":["native_call:pinvoke"],"summary":{"verified":1,"inferred":0,"unresolved":0,"contradicted":0,"native_body_unresolved":0}} {"fixture":"pinvoke-and-runtime-method","implementations":["native_call:pinvoke","Invoke:runtime-provided"],"summary":{"verified":1,"inferred":0,"unresolved":0,"contradicted":0,"native_body_unresolved":1}} ``` The fixture's type isn't a real `System.MulticastDelegate` subclass. The classification uses only the method's impl flags (`ManagedNativeBoundaryHelpers.ts:377`), so a real delegate behaves the same way, as the LibGit2Sharp numbers show. Real-data reproduction: LibGit2Sharp 0.31.0 from nuget.org (`libgit2sharp.0.31.0.nupkg`, sha256 `02fbec03776c0db706363a715973b91758b0f1b86b72d696434303da8252c890`; NuGet SHA-512 verified). Run `inspect-managed-native-boundaries` on `lib/net8.0/LibGit2Sharp.dll` (sha256 `28b694519a8a09bff6db8420236f9e835ec8b504d68bea76aa60beb5a4c41083`), then verify it against any one `analyze_function` record from `LibGit2Sharp.NativeBinaries` 2.0.323. Result: `verified: 1`, `unresolved: 370`, `native_body_unresolved: 356`. ### Cause `src/domain/managed/managedNativeVerificationMatch.ts:374-376` on `main`: ```ts const nativeBodyUnresolved = managed.native_implementations.filter( ({ boundary_kind: kind }) => kind !== "pinvoke", ).length; ``` Every boundary kind other than `pinvoke` counts, `runtime-provided` included. The inspection already separates `runtime-provided` from `native-body`, `unmanaged-method` and `mixed-or-unknown`. ### Expected behavior - `native_body_unresolved` counts only rows that can have a native body for a bridge to map (`native-body`, `unmanaged-method`, and possibly `mixed-or-unknown`), not `runtime-provided`. - `runtime-provided` rows are reported separately, e.g. a `runtime_provided` count with a reason such as "implemented by the CLR at run time; no file-backed native body", or left out of the unresolved summary. - The MCP unknown isn't raised by runtime-provided rows alone. ### Impact and scope - Any assembly that declares a delegate, which covers nearly every non-trivial .NET assembly and especially interop-heavy ones that marshal callbacks. The unresolved count is inflated with rows that can never resolve, and the reason text sends users looking for C++/CLI/ReadyToRun bridge evidence that doesn't apply. - Deterministic. Verified on 6.5.0 (LibGit2Sharp, LibreHardwareMonitor) and on `main` @ `337d2e33c` (fixture above). ### REA version `rea-agents@6.5.0` and `main` @ `337d2e33c`. ### Runtime and platform Linux x64 Docker (`rea` image for 6.5.0, `node:24-bookworm` for `main`, Node 24.21.0) on Windows 11 / Docker Desktop. ### Target and artifact context Synthetic fixture from `scripts/fixtures/managed/pe.mjs`; public NuGet packages LibGit2Sharp 0.31.0 (MIT) and LibGit2Sharp.NativeBinaries 2.0.323. ### Interface CLI. MCP shares the workflow; the unknown-recording consequence is source-derived.
Author
Owner

@XinwangTao commented on GitHub (Oct 15, 2026):

Confirmed — this matches what the impl-flags classification in ManagedNativeBoundaryHelpers.ts:377 would produce, and the LibGit2Sharp numbers make the impact clear: nearly every non-trivial .NET assembly declares at least one delegate, so the unresolved summary gets inflated with rows that can never resolve. Agree that native_body_unresolved should only cover kinds that can actually carry a file-backed native body (native-body, unmanaged-method, and possibly mixed-or-unknown), with runtime-provided reported separately as its own count and excluded from the MCP session-unknown condition. The fixture-based repro you provided should make a good regression test.

<!-- gh-comment-id:6189430322 --> @XinwangTao commented on GitHub (Oct 15, 2026): Confirmed — this matches what the impl-flags classification in ManagedNativeBoundaryHelpers.ts:377 would produce, and the LibGit2Sharp numbers make the impact clear: nearly every non-trivial .NET assembly declares at least one delegate, so the unresolved summary gets inflated with rows that can never resolve. Agree that native_body_unresolved should only cover kinds that can actually carry a file-backed native body (native-body, unmanaged-method, and possibly mixed-or-unknown), with runtime-provided reported separately as its own count and excluded from the MCP session-unknown condition. The fixture-based repro you provided should make a good regression test.
Author
Owner

@Rinkia commented on GitHub (Oct 26, 2026):

Confirmed fixed in rea-agents@6.8.0 with the real-data case from the report (Docker, Node 24.22.1, Ghidra 12.1.4):

  • inspect-managed-native-boundaries on LibGit2Sharp 0.31.0 lib/net8.0/LibGit2Sharp.dll (sha256 28b69451…1083);
  • one native analyze_function record (git_libgit2_init in libgit2-3f4182d.so from LibGit2Sharp.NativeBinaries 2.0.323);
  • verify-managed-native-boundaries on the two.
6.7.0 6.8.0
verified 1 1
unresolved 370 370 (P/Invokes with no native record supplied)
native_body_unresolved 356 (89 delegates × 4) 0

So the runtime-provided delegate methods are no longer counted, which is what morluto's source reading on #4076 said. This can be closed as completed (#4076 was already closed in favour of the main fix).

<!-- gh-comment-id:6412665485 --> @Rinkia commented on GitHub (Oct 26, 2026): Confirmed fixed in `rea-agents@6.8.0` with the real-data case from the report (Docker, Node 24.22.1, Ghidra 12.1.4): - `inspect-managed-native-boundaries` on LibGit2Sharp 0.31.0 `lib/net8.0/LibGit2Sharp.dll` (sha256 `28b69451…1083`); - one native `analyze_function` record (`git_libgit2_init` in `libgit2-3f4182d.so` from LibGit2Sharp.NativeBinaries 2.0.323); - `verify-managed-native-boundaries` on the two. | | 6.7.0 | 6.8.0 | |---|---|---| | `verified` | 1 | 1 | | `unresolved` | 370 | 370 (P/Invokes with no native record supplied) | | `native_body_unresolved` | 356 (89 delegates × 4) | **0** | So the runtime-provided delegate methods are no longer counted, which is what morluto's source reading on #4076 said. This can be closed as completed (#4076 was already closed in favour of the main fix).
Author
Owner

@morluto commented on GitHub (Oct 26, 2026):

Thank you for separating CLR-provided methods from file-backed native implementations and for the real assembly evidence. Main addresses this in bc2d49403: runtime-provided rows are counted separately and excluded from native_body_unresolved, which also prevents those rows alone from triggering the unresolved-summary condition. The managed verification regression covers a runtime-provided fixture. I am closing this as fixed; this triage inspected the source and existing regression, without rerunning the NuGet assemblies or MCP workflow.

<!-- gh-comment-id:6428633243 --> @morluto commented on GitHub (Oct 26, 2026): Thank you for separating CLR-provided methods from file-backed native implementations and for the real assembly evidence. Main addresses this in bc2d49403: runtime-provided rows are counted separately and excluded from `native_body_unresolved`, which also prevents those rows alone from triggering the unresolved-summary condition. The managed verification regression covers a runtime-provided fixture. I am closing this as fixed; this triage inspected the source and existing regression, without rerunning the NuGet assemblies or MCP workflow.
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
morluto/rea#1603
No description provided.