fix(reverse): reconcile cleanup ownership and mutation diagnostics (#2799)
* feat(process): add owned debugger shutdown lifecycle * feat(reverse): add local inspection commands * feat(gdb): add persistent debugger integration * feat(cutter): add Cutter bridge integration * test(reverse): enforce complete integration coverage * fix(process): preserve debugger shutdown ownership * fix(reverse): retain evidence across interrupted commands * fix(gdb): honor captured provider environment * test(process): cover debugger shutdown retries * fix(reverse): retain evidence for interrupted providers * test(process): cover debugger shutdown retries * fix(reverse): retain evidence for interrupted providers * fix(server): snapshot provider environment at creation * fix(reverse): close review gaps with complete coverage * fix(reverse): address inspection review findings * fix(gdb): bound sessions and retain cleanup ownership * fix(ci): require reverse-tools quality lane * fix(process): unref inherited debugger output pipes * fix(reverse): address remaining PR 1960 findings * fix(gdb): address remaining PR 1961 findings * fix(reverse): account for unrestricted provider execution * fix(process): retain debugger ownership until safe shutdown * fix(process): preserve replacement debugger sessions * fix(reverse): preserve admission and output provenance * fix(process): retain exited debugger until output settles * fix(mcp): bound provider admission and debugger cleanup * fix(reverse): preserve failure provenance and classify E2BIG * fix(gdb): preserve startup cleanup and detach safety * fix(process): retain debugger ownership after detach failure * fix(reverse): preserve mutation and failure provenance * fix(gdb): address debugger review findings * fix(reverse): rebind sessions after external mutation * fix(process): cap debugger output after exit * fix(gdb): protect one-shot detach verification * feat(debugger): add reverse tool coverage gate * test(debugger): cover integrated failure paths * fix(gdb): bound and preserve session observations * fix(gdb): address review regressions * style: format merged integration files * fix(reverse): preserve session and output evidence * refactor(gdb): share repeated test and partial result setup * fix(gdb): invalidate TTY proof for Python aliases * fix(reverse): retain failure output byte counts * test(process): synchronize late debugger output * fix(gdb): invalidate TTY proof for wrapped commands * fix(gdb): recognize wit command abbreviation * test(reverse): wait for cancelled descendant readiness * test(reverse): bound descendant cancellation cleanup * style(reverse): format integration tests and adapters * style: format reverse engineering integration files * test(reverse): remove duplicate environment harness * test(gdb): share session startup assertions * fix(gdb): invalidate output proof for command abbreviations * test(reverse): share composition edge case scenarios * fix(ci): recognize verification and mutation tool entries * test(rizin): cover successful fake inferior detach * test(cli): align full output failure envelope * test(cli): isolate reverse command lifecycle service * test(process): share debugger output fixture setup * fix(binary): preserve high-bit Windows signature bytes * style(binary): format Windows signature parser * test(mcp): preserve call coverage in unchanged comparison * fix(ci): include reverse tools Vitest configs in lint cache * fix(mcp): preserve target content on delivery errors * ci: align duplication ceiling with expanded suite * test(reverse): allow signal admission mutants * fix(reverse): bound retained cleanup failures to process owners * fix(test): replace drifting mutation ranges with owned selectors * fix(reverse): admit persistent commands and retain cleanup ownership * fix(cutter): recover timed-out commands from exact receipts * fix(cutter): reject unsupported bridge request kinds * fix(ci): repair client format and shared verification fixtures * fix(native): preserve debugger cleanup ownership and bridge authentication * test(native): retain diagnostics without perfect coverage requirements * chore(licenses): refresh locked dependency inventory and notices * fix(test): exclude transient command locks from mutation sandboxes * fix(gdb): admit only platforms with safe detach verification * fix(cutter): retain bounded completion receipts until acknowledgement * fix(cutter): acknowledge commands rejected before UI dispatch * test(reverse): fail cancellation checks before fixture teardown * test(gdb): clean up the retained broken-stdin fixture owner * docs(test): clarify focused provider verification scope * fix(rizin): retain close cleanup failures * fix(gdb): wait for inferior stop and bound MI buffering * fix(cutter): retain failed receipt acknowledgements * fix(rizin): return retained session identity on rebind failure * fix(gdb): preserve startup cancellation and lossy status * fix(rizin): retain launch provenance and assemble frames once * fix(cutter): recover receipts across callers and bound discovery * docs: describe debugger evidence and bridge recovery * fix(process): keep cleanup incomplete while writers remain Stopping a debugger no longer reports success when inherited output streams stay open, so BinarySession cannot reopen a binary a child is still changing. Concurrent Cutter cleanup retries share one receipt acknowledgement instead of treating a consumed receipt as failure. * fix(debuggers): recover cleanup with captured process identities * fix(analysis): retire sessions after unrestricted commands * fix(cli): preserve completed evidence after cleanup recovery * fix(cutter): bound frame collection and coalesce cleanup * fix(windows): resolve selected provider paths absolutely * fix(gdb): report decoding loss in failed startup evidence * fix(rizin): scope decoding loss to collected command frames * test(mcp): cover snapshot retirement in reverse tools CI --------- Co-authored-by: Daniel Gonçalves Araujo <47118053+dandgabr@users.noreply.github.com> Co-authored-by: REA Maintainers <noreply@github.com> Co-authored-by: OpenAI Codex <codex@users.noreply.github.com> Co-authored-by: Cursor Agent <cursoragent@cursor.com>
This commit is contained in:
parent
2ad2a044d4
commit
4aa51bfe19
|
|
@ -31,6 +31,49 @@ Diagnose the observed blocker, continue useful supported work, and resume the
|
|||
original task after repair. Client approval, shell PATH, registration and engine
|
||||
availability have different remedies; avoid repeating setup for all of them.
|
||||
|
||||
1. Diagnose without changing REA or client configuration. `npx` may download
|
||||
the package and write to npm's cache. For Codex, run
|
||||
`npx -y rea-agents@latest doctor --client codex --json`. Substitute the current
|
||||
supported client: `claude_code`, `claude_desktop`, `codex`, `cursor`,
|
||||
`gemini_cli`, `windsurf`, `devin`, `opencode`, `antigravity`, `copilot_cli`,
|
||||
`commandcode`, `vscode`, `grok_build`, `omp`, or `grok_bot`. `grok_bot` has no local
|
||||
file registration: doctor reports the Grok Bot chat step, and setup does not
|
||||
write `mcp.json`. If the client is unknown, use `doctor --json` and
|
||||
inspect its registration results before choosing a setup scope.
|
||||
2. Distinguish the reason. Missing, malformed, or stale registration needs a
|
||||
scoped configuration repair. An aligned registration with no tools in the
|
||||
active session needs a restart/reconnection; doctor cannot prove that the
|
||||
current agent has connected. A missing provider affects only tasks requiring
|
||||
that provider: static JavaScript inspection needs neither Hopper nor Ghidra,
|
||||
and Android inspection has separate bring-your-own JADX/Java prerequisites.
|
||||
3. For a configuration repair, prepare the read-only plan:
|
||||
`npx -y rea-agents@latest setup --client codex --dry-run --json`.
|
||||
Use the current client's ID, show its exact proposed paths, backups, and
|
||||
changes, and obtain approval before setup writes configuration or installs
|
||||
Hopper. Setup normally installs the matching bundled skill too; include that
|
||||
replacement in the reviewed plan. After approval, apply the same scope with
|
||||
`npx -y rea-agents@latest setup --client codex --yes`. Add `--install-hopper`
|
||||
only if that separate installation was needed and explicitly approved.
|
||||
4. Restart/reconnect the affected agent. Verify that REA tools actually appear
|
||||
in the session, then resume the original investigation. If they remain
|
||||
absent, inspect the client's MCP launch error rather than repeating setup.
|
||||
|
||||
REA setup never installs or upgrades Node.js, npm, Homebrew, Java, Ghidra, IDA,
|
||||
JADX, Binwalk, or Unblob. Use existing prerequisites; do not install unrelated
|
||||
software to repair
|
||||
MCP registration. For an unsupported client, use manual stdio registration
|
||||
with a version-pinned `rea-agents` package or continue through the CLI.
|
||||
|
||||
A concrete CLI fallback for an operator-supplied JavaScript tree or ASAR is:
|
||||
|
||||
```bash
|
||||
npx -y rea-agents@latest analyze-javascript-application /absolute/path/to/app --json
|
||||
```
|
||||
|
||||
No MCP registration or native engine is required. Read the returned Evidence,
|
||||
graph, limitations, and unknowns with the same care as an MCP result; the CLI
|
||||
returns the Evidence record directly. This fallback does not establish that
|
||||
MCP is configured. Native CLI tasks still require their selected engine.
|
||||
Prefer an existing `rea` CLI. When none is installed and REA package acquisition
|
||||
is within the authorized scope, use `npx -y rea-agents@latest` in its place.
|
||||
Configuration repair starts with a read-only setup plan; show paths, backups and
|
||||
|
|
|
|||
|
|
@ -42,6 +42,7 @@ jobs:
|
|||
tier: ${{ steps.plan.outputs.tier }}
|
||||
qualification_run_id: ${{ steps.qualification.outputs.run_id }}
|
||||
code: ${{ steps.plan.outputs.code }}
|
||||
reverse_tools: ${{ steps.plan.outputs.reverse_tools }}
|
||||
sqlite: ${{ steps.plan.outputs.sqlite }}
|
||||
runtime: ${{ steps.plan.outputs.runtime }}
|
||||
package: ${{ steps.plan.outputs.package }}
|
||||
|
|
@ -211,6 +212,24 @@ jobs:
|
|||
overwrite: true
|
||||
retention-days: 7
|
||||
|
||||
reverse-tools-quality:
|
||||
name: Reverse tools coverage and mutation
|
||||
needs: [changes, static]
|
||||
if: needs.changes.outputs.reverse_tools == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 60
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version-file: .nvmrc
|
||||
cache: npm
|
||||
- run: npm ci
|
||||
- name: Run reverse tools tests with coverage
|
||||
run: npm run test:coverage:reverse-tools
|
||||
- name: Report reverse tools mutation results
|
||||
run: npm run test:mutation:reverse-tools
|
||||
|
||||
build:
|
||||
name: Build package
|
||||
needs: changes
|
||||
|
|
@ -826,6 +845,7 @@ jobs:
|
|||
changes,
|
||||
static,
|
||||
build,
|
||||
reverse-tools-quality,
|
||||
test-shard,
|
||||
sqlite-runtime,
|
||||
test,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,74 @@
|
|||
name: Real upstream Cutter verification
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
repository_dispatch:
|
||||
types: [real-cutter-upstream]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
windows-native-artifact:
|
||||
name: Build Windows bridge security boundary
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version-file: .nvmrc
|
||||
- name: Install the cross compiler used by the Windows native lane
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install --no-install-recommends -y g++-mingw-w64-x86-64
|
||||
- run: npm run build:windows-native
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: cutter-windows-native
|
||||
path: native/windows/build/
|
||||
if-no-files-found: error
|
||||
|
||||
cutter-upstream:
|
||||
name: Cutter upstream smoke (${{ matrix.platform }})
|
||||
needs: windows-native-artifact
|
||||
runs-on: ${{ matrix.runner }}
|
||||
timeout-minutes: 20
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- platform: linux
|
||||
runner: [self-hosted, cutter-upstream, linux]
|
||||
- platform: macOS
|
||||
runner: [self-hosted, cutter-upstream, macOS]
|
||||
- platform: Windows
|
||||
runner: [self-hosted, cutter-upstream, Windows]
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version-file: .nvmrc
|
||||
cache: npm
|
||||
- run: npm ci
|
||||
- if: matrix.platform == 'Windows'
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: cutter-windows-native
|
||||
path: native/windows/build/
|
||||
- if: matrix.platform == 'Windows'
|
||||
run: npm run verify:windows-native:artifact
|
||||
- if: matrix.platform == 'Windows'
|
||||
run: npm run build:cached
|
||||
- if: matrix.platform == 'Windows'
|
||||
run: npm run verify:windows-native
|
||||
- name: Verify host and real Cutter bridge
|
||||
env:
|
||||
EXPECTED_PLATFORM: ${{ matrix.platform }}
|
||||
run: npm run verify:cutter
|
||||
- uses: actions/upload-artifact@v4
|
||||
if: always()
|
||||
with:
|
||||
name: cutter-upstream-${{ matrix.platform }}
|
||||
path: cutter-smoke.json
|
||||
if-no-files-found: ignore
|
||||
retention-days: 30
|
||||
|
|
@ -28,7 +28,7 @@ jobs:
|
|||
- uses: ./.github/actions/portable-build-cache
|
||||
with:
|
||||
build_cache_artifact: ${{ inputs.build_cache_artifact }}
|
||||
- run: npm run verify:browser:runtime
|
||||
- run: env -u DBUS_SESSION_BUS_ADDRESS npm run verify:browser:runtime
|
||||
env:
|
||||
REA_BROWSER_EXECUTABLE: /usr/bin/google-chrome
|
||||
REA_BROWSER_NO_SANDBOX: "true"
|
||||
|
|
|
|||
|
|
@ -33,6 +33,8 @@ codedb.snapshot
|
|||
htmlcov/
|
||||
.coverage
|
||||
coverage/
|
||||
.stryker-tmp/
|
||||
reports/mutation/
|
||||
.vitest/
|
||||
.vitest-reports/
|
||||
test-results.xml
|
||||
|
|
|
|||
|
|
@ -186,6 +186,11 @@ Static JavaScript and .NET inspection read the supplied files without running
|
|||
the application. Runtime capture runs or interacts with the selected target
|
||||
using your user permissions; each runtime guide describes its effects.
|
||||
|
||||
For offline native-binary inspection, REA also supports caller-supplied GNU
|
||||
objdump and Rizin. See the [reverse-engineering tools guide](docs/reverse-engineering-tools.md)
|
||||
for provider setup, CLI commands, and Evidence limitations. REA does not install
|
||||
or update either provider.
|
||||
|
||||
<a id="choosing-a-deep-analysis-provider"></a>
|
||||
|
||||
Native formats and host support vary by provider. See
|
||||
|
|
|
|||
|
|
@ -0,0 +1,816 @@
|
|||
"""REA's experimental local bridge plugin for Cutter.
|
||||
|
||||
Install this file in Cutter's user plugin directory. The bridge is REA-owned
|
||||
IPC, not an upstream Cutter transport. It binds to loopback and requires a
|
||||
per-process random token stored in a user-only descriptor file.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import hashlib
|
||||
import hmac
|
||||
import os
|
||||
import secrets
|
||||
import socket
|
||||
import stat
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
if os.name == "nt":
|
||||
import ctypes
|
||||
from ctypes import wintypes
|
||||
|
||||
import cutter
|
||||
|
||||
try:
|
||||
from PySide6.QtCore import QObject, Signal
|
||||
except ImportError:
|
||||
from PySide2.QtCore import QObject, Signal
|
||||
|
||||
|
||||
MAX_FRAME_BYTES = 16 * 1024 * 1024
|
||||
SOCKET_TIMEOUT_SECONDS = 20
|
||||
AUTH_FRAME_TIMEOUT_SECONDS = 1
|
||||
MAX_DESCRIPTOR_BYTES = 64 * 1024
|
||||
MAX_COMPLETION_RECEIPTS = 64
|
||||
|
||||
|
||||
def _windows_api() -> tuple[Any, Any]:
|
||||
kernel32 = ctypes.WinDLL("kernel32", use_last_error=True)
|
||||
advapi32 = ctypes.WinDLL("advapi32", use_last_error=True)
|
||||
kernel32.GetCurrentProcess.restype = wintypes.HANDLE
|
||||
kernel32.LocalFree.argtypes = [wintypes.HLOCAL]
|
||||
kernel32.LocalFree.restype = wintypes.HLOCAL
|
||||
kernel32.CloseHandle.argtypes = [wintypes.HANDLE]
|
||||
kernel32.CloseHandle.restype = wintypes.BOOL
|
||||
kernel32.GetFileAttributesW.argtypes = [wintypes.LPCWSTR]
|
||||
kernel32.GetFileAttributesW.restype = wintypes.DWORD
|
||||
kernel32.CreateDirectoryW.argtypes = [wintypes.LPCWSTR, wintypes.LPVOID]
|
||||
kernel32.CreateDirectoryW.restype = wintypes.BOOL
|
||||
kernel32.CreateFileW.argtypes = [wintypes.LPCWSTR, wintypes.DWORD, wintypes.DWORD,
|
||||
wintypes.LPVOID, wintypes.DWORD, wintypes.DWORD, wintypes.HANDLE]
|
||||
kernel32.CreateFileW.restype = wintypes.HANDLE
|
||||
kernel32.WriteFile.argtypes = [wintypes.HANDLE, wintypes.LPVOID, wintypes.DWORD,
|
||||
ctypes.POINTER(wintypes.DWORD), wintypes.LPVOID]
|
||||
kernel32.WriteFile.restype = wintypes.BOOL
|
||||
kernel32.FlushFileBuffers.argtypes = [wintypes.HANDLE]
|
||||
kernel32.FlushFileBuffers.restype = wintypes.BOOL
|
||||
kernel32.MoveFileExW.argtypes = [wintypes.LPCWSTR, wintypes.LPCWSTR, wintypes.DWORD]
|
||||
kernel32.MoveFileExW.restype = wintypes.BOOL
|
||||
kernel32.DeleteFileW.argtypes = [wintypes.LPCWSTR]
|
||||
kernel32.DeleteFileW.restype = wintypes.BOOL
|
||||
advapi32.OpenProcessToken.argtypes = [wintypes.HANDLE, wintypes.DWORD, ctypes.POINTER(wintypes.HANDLE)]
|
||||
advapi32.OpenProcessToken.restype = wintypes.BOOL
|
||||
advapi32.GetTokenInformation.argtypes = [wintypes.HANDLE, ctypes.c_int, wintypes.LPVOID,
|
||||
wintypes.DWORD, ctypes.POINTER(wintypes.DWORD)]
|
||||
advapi32.GetTokenInformation.restype = wintypes.BOOL
|
||||
advapi32.ConvertSidToStringSidW.argtypes = [wintypes.LPVOID, ctypes.POINTER(wintypes.LPWSTR)]
|
||||
advapi32.ConvertSidToStringSidW.restype = wintypes.BOOL
|
||||
advapi32.GetNamedSecurityInfoW.argtypes = [wintypes.LPWSTR, ctypes.c_int, wintypes.DWORD,
|
||||
ctypes.POINTER(wintypes.LPVOID), ctypes.POINTER(wintypes.LPVOID),
|
||||
ctypes.POINTER(wintypes.LPVOID), ctypes.POINTER(wintypes.LPVOID),
|
||||
ctypes.POINTER(wintypes.LPVOID)]
|
||||
advapi32.GetNamedSecurityInfoW.restype = wintypes.DWORD
|
||||
advapi32.GetSecurityDescriptorControl.argtypes = [wintypes.LPVOID, ctypes.POINTER(wintypes.WORD),
|
||||
ctypes.POINTER(wintypes.DWORD)]
|
||||
advapi32.GetSecurityDescriptorControl.restype = wintypes.BOOL
|
||||
advapi32.EqualSid.argtypes = [wintypes.LPVOID, wintypes.LPVOID]
|
||||
advapi32.EqualSid.restype = wintypes.BOOL
|
||||
advapi32.GetAclInformation.argtypes = [wintypes.LPVOID, wintypes.LPVOID, wintypes.DWORD, ctypes.c_int]
|
||||
advapi32.GetAclInformation.restype = wintypes.BOOL
|
||||
advapi32.GetAce.argtypes = [wintypes.LPVOID, wintypes.DWORD, ctypes.POINTER(wintypes.LPVOID)]
|
||||
advapi32.GetAce.restype = wintypes.BOOL
|
||||
advapi32.IsValidSid.argtypes = [wintypes.LPVOID]
|
||||
advapi32.IsValidSid.restype = wintypes.BOOL
|
||||
advapi32.GetLengthSid.argtypes = [wintypes.LPVOID]
|
||||
advapi32.GetLengthSid.restype = wintypes.DWORD
|
||||
advapi32.ConvertStringSecurityDescriptorToSecurityDescriptorW.argtypes = [
|
||||
wintypes.LPCWSTR, wintypes.DWORD, ctypes.POINTER(wintypes.LPVOID), ctypes.POINTER(wintypes.DWORD),
|
||||
]
|
||||
advapi32.ConvertStringSecurityDescriptorToSecurityDescriptorW.restype = wintypes.BOOL
|
||||
return kernel32, advapi32
|
||||
|
||||
|
||||
def _windows_user_sid_text() -> str:
|
||||
kernel32, advapi32 = _windows_api()
|
||||
token = wintypes.HANDLE()
|
||||
if not advapi32.OpenProcessToken(kernel32.GetCurrentProcess(), 0x0008, ctypes.byref(token)):
|
||||
raise ctypes.WinError(ctypes.get_last_error())
|
||||
try:
|
||||
required = wintypes.DWORD()
|
||||
advapi32.GetTokenInformation(token, 1, None, 0, ctypes.byref(required))
|
||||
if required.value == 0:
|
||||
raise ctypes.WinError(ctypes.get_last_error())
|
||||
buffer = ctypes.create_string_buffer(required.value)
|
||||
if not advapi32.GetTokenInformation(token, 1, buffer, required, ctypes.byref(required)):
|
||||
raise ctypes.WinError(ctypes.get_last_error())
|
||||
|
||||
class SID_AND_ATTRIBUTES(ctypes.Structure):
|
||||
_fields_ = [("Sid", wintypes.LPVOID), ("Attributes", wintypes.DWORD)]
|
||||
|
||||
class TOKEN_USER(ctypes.Structure):
|
||||
_fields_ = [("User", SID_AND_ATTRIBUTES)]
|
||||
|
||||
sid = ctypes.cast(buffer, ctypes.POINTER(TOKEN_USER)).contents.User.Sid
|
||||
text = wintypes.LPWSTR()
|
||||
if not advapi32.ConvertSidToStringSidW(sid, ctypes.byref(text)):
|
||||
raise ctypes.WinError(ctypes.get_last_error())
|
||||
try:
|
||||
return text.value
|
||||
finally:
|
||||
kernel32.LocalFree(text)
|
||||
finally:
|
||||
kernel32.CloseHandle(token)
|
||||
|
||||
|
||||
def _verify_windows_private_path(path: Path, directory: bool) -> None:
|
||||
kernel32, advapi32 = _windows_api()
|
||||
attributes = kernel32.GetFileAttributesW(str(path))
|
||||
if attributes == 0xFFFFFFFF:
|
||||
raise ctypes.WinError(ctypes.get_last_error())
|
||||
if attributes & 0x400 or bool(attributes & 0x10) != directory:
|
||||
raise RuntimeError("Cutter bridge path is a reparse point or has the wrong object type")
|
||||
|
||||
class ACL_SIZE_INFORMATION(ctypes.Structure):
|
||||
_fields_ = [("AceCount", wintypes.DWORD), ("AclBytesInUse", wintypes.DWORD), ("AclBytesFree", wintypes.DWORD)]
|
||||
|
||||
class ACE_HEADER(ctypes.Structure):
|
||||
_fields_ = [("AceType", wintypes.BYTE), ("AceFlags", wintypes.BYTE), ("AceSize", wintypes.WORD)]
|
||||
|
||||
class SID_HEADER(ctypes.Structure):
|
||||
_fields_ = [("Revision", wintypes.BYTE), ("SubAuthorityCount", wintypes.BYTE),
|
||||
("IdentifierAuthority", wintypes.BYTE * 6)]
|
||||
|
||||
class ACCESS_ALLOWED_ACE(ctypes.Structure):
|
||||
_fields_ = [("Header", ACE_HEADER), ("Mask", wintypes.DWORD), ("SidStart", wintypes.DWORD)]
|
||||
|
||||
owner = wintypes.LPVOID()
|
||||
dacl = wintypes.LPVOID()
|
||||
descriptor = wintypes.LPVOID()
|
||||
result = advapi32.GetNamedSecurityInfoW(
|
||||
str(path), 1, 0x1 | 0x4, ctypes.byref(owner), None, ctypes.byref(dacl), None, ctypes.byref(descriptor),
|
||||
)
|
||||
if result != 0:
|
||||
raise ctypes.WinError(result)
|
||||
try:
|
||||
control = wintypes.WORD()
|
||||
revision = wintypes.DWORD()
|
||||
if not advapi32.GetSecurityDescriptorControl(descriptor, ctypes.byref(control), ctypes.byref(revision)):
|
||||
raise ctypes.WinError(ctypes.get_last_error())
|
||||
if not control.value & 0x1000 or not dacl:
|
||||
raise RuntimeError("Cutter bridge ACL must be present and protected from inheritance")
|
||||
|
||||
token = wintypes.HANDLE()
|
||||
if not advapi32.OpenProcessToken(kernel32.GetCurrentProcess(), 0x0008, ctypes.byref(token)):
|
||||
raise ctypes.WinError(ctypes.get_last_error())
|
||||
try:
|
||||
required = wintypes.DWORD()
|
||||
advapi32.GetTokenInformation(token, 1, None, 0, ctypes.byref(required))
|
||||
token_user = ctypes.create_string_buffer(required.value)
|
||||
if not advapi32.GetTokenInformation(token, 1, token_user, required, ctypes.byref(required)):
|
||||
raise ctypes.WinError(ctypes.get_last_error())
|
||||
|
||||
class SID_AND_ATTRIBUTES(ctypes.Structure):
|
||||
_fields_ = [("Sid", wintypes.LPVOID), ("Attributes", wintypes.DWORD)]
|
||||
|
||||
class TOKEN_USER(ctypes.Structure):
|
||||
_fields_ = [("User", SID_AND_ATTRIBUTES)]
|
||||
|
||||
current_sid = ctypes.cast(token_user, ctypes.POINTER(TOKEN_USER)).contents.User.Sid
|
||||
if not advapi32.EqualSid(owner, current_sid):
|
||||
raise RuntimeError("Cutter bridge path owner does not match the current user")
|
||||
finally:
|
||||
kernel32.CloseHandle(token)
|
||||
|
||||
info = ACL_SIZE_INFORMATION()
|
||||
if not advapi32.GetAclInformation(dacl, ctypes.byref(info), ctypes.sizeof(info), 2) or info.AceCount != 2:
|
||||
raise RuntimeError("Cutter bridge ACL must allow only the current user and SYSTEM")
|
||||
expected_user = _windows_user_sid_text()
|
||||
user_allowed = False
|
||||
system_allowed = False
|
||||
for index in range(info.AceCount):
|
||||
ace_pointer = wintypes.LPVOID()
|
||||
if not advapi32.GetAce(dacl, index, ctypes.byref(ace_pointer)):
|
||||
raise ctypes.WinError(ctypes.get_last_error())
|
||||
header = ctypes.cast(ace_pointer, ctypes.POINTER(ACE_HEADER)).contents
|
||||
if header.AceType != 0 or header.AceSize < ctypes.sizeof(ACE_HEADER) + ctypes.sizeof(wintypes.DWORD) + ctypes.sizeof(SID_HEADER):
|
||||
raise RuntimeError("Cutter bridge ACL contains a malformed or unsupported ACE")
|
||||
ace_address = ctypes.cast(ace_pointer, ctypes.c_void_p).value
|
||||
if ace_address is None:
|
||||
raise RuntimeError("Cutter bridge ACL returned a null ACE")
|
||||
sid_pointer = ctypes.c_void_p(ace_address + ctypes.sizeof(ACE_HEADER) + ctypes.sizeof(wintypes.DWORD))
|
||||
sid_header = ctypes.cast(sid_pointer, ctypes.POINTER(SID_HEADER)).contents
|
||||
sid_size = ctypes.sizeof(SID_HEADER) + sid_header.SubAuthorityCount * ctypes.sizeof(wintypes.DWORD)
|
||||
if sid_size > header.AceSize - ctypes.sizeof(ACE_HEADER) - ctypes.sizeof(wintypes.DWORD) or not advapi32.IsValidSid(sid_pointer) or advapi32.GetLengthSid(sid_pointer) != sid_size:
|
||||
raise RuntimeError("Cutter bridge ACL contains an invalid SID")
|
||||
ace = ctypes.cast(ace_pointer, ctypes.POINTER(ACCESS_ALLOWED_ACE)).contents
|
||||
sid_text = wintypes.LPWSTR()
|
||||
if ace.Mask != 0x001F01FF or ace.Header.AceFlags != 0x03:
|
||||
raise RuntimeError("Cutter bridge ACL contains an unsupported ACE")
|
||||
if not advapi32.ConvertSidToStringSidW(sid_pointer, ctypes.byref(sid_text)):
|
||||
raise ctypes.WinError(ctypes.get_last_error())
|
||||
try:
|
||||
if sid_text.value == expected_user:
|
||||
user_allowed = True
|
||||
elif sid_text.value == "S-1-5-18":
|
||||
system_allowed = True
|
||||
else:
|
||||
raise RuntimeError("Cutter bridge ACL allows an unrelated principal")
|
||||
finally:
|
||||
kernel32.LocalFree(sid_text)
|
||||
if not user_allowed or not system_allowed:
|
||||
raise RuntimeError("Cutter bridge ACL must allow only the current user and SYSTEM")
|
||||
finally:
|
||||
kernel32.LocalFree(descriptor)
|
||||
|
||||
|
||||
def _windows_security_attributes() -> tuple[Any, Any]:
|
||||
kernel32, advapi32 = _windows_api()
|
||||
sid = _windows_user_sid_text()
|
||||
sddl = f"O:{sid}G:{sid}D:P(A;OICI;FA;;;{sid})(A;OICI;FA;;;SY)"
|
||||
descriptor = wintypes.LPVOID()
|
||||
if not advapi32.ConvertStringSecurityDescriptorToSecurityDescriptorW(sddl, 1, ctypes.byref(descriptor), None):
|
||||
raise ctypes.WinError(ctypes.get_last_error())
|
||||
|
||||
class SECURITY_ATTRIBUTES(ctypes.Structure):
|
||||
_fields_ = [("nLength", wintypes.DWORD), ("lpSecurityDescriptor", wintypes.LPVOID), ("bInheritHandle", wintypes.BOOL)]
|
||||
|
||||
attributes = SECURITY_ATTRIBUTES(ctypes.sizeof(SECURITY_ATTRIBUTES), descriptor, False)
|
||||
return kernel32, (descriptor, attributes)
|
||||
|
||||
|
||||
def _ensure_windows_bridge_directory(directory: Path) -> None:
|
||||
directory.parent.mkdir(parents=True, exist_ok=True)
|
||||
if not directory.exists():
|
||||
kernel32, (descriptor, attributes) = _windows_security_attributes()
|
||||
try:
|
||||
if not kernel32.CreateDirectoryW(str(directory), ctypes.byref(attributes)):
|
||||
error = ctypes.get_last_error()
|
||||
if error != 183:
|
||||
raise ctypes.WinError(error)
|
||||
finally:
|
||||
kernel32.LocalFree(descriptor)
|
||||
_verify_windows_private_path(directory, True)
|
||||
|
||||
|
||||
def _ensure_posix_bridge_directory(directory: Path) -> None:
|
||||
directory.mkdir(mode=0o700, parents=True, exist_ok=True)
|
||||
if not hasattr(os, "O_NOFOLLOW") or not hasattr(os, "O_DIRECTORY"):
|
||||
raise RuntimeError("Cutter bridge directory requires no-follow directory handles")
|
||||
descriptor = os.open(directory, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW)
|
||||
try:
|
||||
directory_info = os.fstat(descriptor)
|
||||
if directory_info.st_uid != os.getuid():
|
||||
raise RuntimeError("Cutter bridge directory must be owned by the current user")
|
||||
os.fchmod(descriptor, 0o700)
|
||||
directory_info = os.fstat(descriptor)
|
||||
if stat.S_IMODE(directory_info.st_mode) != 0o700:
|
||||
raise RuntimeError("Cutter bridge directory must have mode 0700")
|
||||
finally:
|
||||
os.close(descriptor)
|
||||
|
||||
|
||||
def _write_windows_descriptor(directory: Path, destination: Path, payload: bytes) -> None:
|
||||
if len(payload) > MAX_DESCRIPTOR_BYTES:
|
||||
raise RuntimeError("Cutter bridge descriptor exceeds its byte limit")
|
||||
kernel32, (security_descriptor, attributes) = _windows_security_attributes()
|
||||
temporary = directory / f".{destination.name}.{uuid.uuid4().hex}.tmp"
|
||||
handle = wintypes.HANDLE()
|
||||
try:
|
||||
handle = kernel32.CreateFileW(str(temporary), 0x40000000 | 0x00020000 | 0x00040000,
|
||||
0x1 | 0x2 | 0x4, ctypes.byref(attributes), 1, 0x80, None)
|
||||
if handle == wintypes.HANDLE(-1).value:
|
||||
raise ctypes.WinError(ctypes.get_last_error())
|
||||
_verify_windows_private_path(temporary, False)
|
||||
if payload:
|
||||
data = ctypes.create_string_buffer(payload)
|
||||
written = wintypes.DWORD()
|
||||
if not kernel32.WriteFile(handle, data, len(payload), ctypes.byref(written), None) or written.value != len(payload):
|
||||
raise ctypes.WinError(ctypes.get_last_error())
|
||||
if not kernel32.FlushFileBuffers(handle):
|
||||
raise ctypes.WinError(ctypes.get_last_error())
|
||||
kernel32.CloseHandle(handle)
|
||||
handle = wintypes.HANDLE()
|
||||
if not kernel32.MoveFileExW(str(temporary), str(destination), 0x1 | 0x8):
|
||||
raise ctypes.WinError(ctypes.get_last_error())
|
||||
finally:
|
||||
if handle and handle != wintypes.HANDLE(-1).value:
|
||||
kernel32.CloseHandle(handle)
|
||||
kernel32.DeleteFileW(str(temporary))
|
||||
kernel32.LocalFree(security_descriptor)
|
||||
|
||||
|
||||
def _bridge_directory() -> Path:
|
||||
configured = os.environ.get("REA_CUTTER_BRIDGE_DIR")
|
||||
if configured:
|
||||
return Path(configured).expanduser()
|
||||
local_app_data = os.environ.get("LOCALAPPDATA")
|
||||
if os.name == "nt" and local_app_data:
|
||||
return Path(local_app_data) / "REA" / "CutterBridge"
|
||||
return Path.home() / ".cache" / "rea" / "cutter-bridge"
|
||||
|
||||
|
||||
class _RequestDispatcher(QObject):
|
||||
dispatch = Signal(object, object)
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
self.dispatch.connect(self._handle)
|
||||
self.generation = 0
|
||||
self.completed_commands: dict[str, str] = {}
|
||||
self._receipt_lock = threading.Lock()
|
||||
self.current_file = self._read_current_file()
|
||||
try:
|
||||
self.cutter_version = str(cutter.core().getVersionInformation())
|
||||
except Exception:
|
||||
self.cutter_version = None
|
||||
|
||||
@staticmethod
|
||||
def _read_current_file() -> str | None:
|
||||
try:
|
||||
value = cutter.core().currentlyOpenFile()
|
||||
return str(value) if value else None
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
def _handle(self, request: dict[str, Any], reply: dict[str, Any]) -> None:
|
||||
kind = request.get("kind")
|
||||
request_id = request.get("request_id")
|
||||
if kind in ("command", "ack"):
|
||||
try:
|
||||
valid_request_id = (
|
||||
isinstance(request_id, str)
|
||||
and str(uuid.UUID(request_id)) == request_id.lower()
|
||||
)
|
||||
except (ValueError, AttributeError):
|
||||
valid_request_id = False
|
||||
if not valid_request_id:
|
||||
reply.update({
|
||||
"ok": False,
|
||||
"error": "invalid-command-request-id",
|
||||
"execution_state": "not_started",
|
||||
})
|
||||
request["_event"].set()
|
||||
return
|
||||
if kind == "ack":
|
||||
with self._receipt_lock:
|
||||
acknowledged = self.completed_commands.pop(request_id, None) is not None
|
||||
reply.update({"ok": True, "acknowledged": acknowledged})
|
||||
request["_event"].set()
|
||||
return
|
||||
if request["_cancelled"].is_set():
|
||||
reply.update({
|
||||
"ok": False,
|
||||
"error": "request-cancelled-before-execution",
|
||||
"execution_state": "not_started",
|
||||
})
|
||||
if kind == "command":
|
||||
self._record_command_completion(request_id, "not_started")
|
||||
request["_event"].set()
|
||||
return
|
||||
self._refresh_document_identity()
|
||||
if kind == "status":
|
||||
with self._receipt_lock:
|
||||
completed_commands = [
|
||||
{"request_id": receipt_id, "execution_state": state}
|
||||
for receipt_id, state in self.completed_commands.items()
|
||||
]
|
||||
reply.update({
|
||||
"ok": True,
|
||||
"session_id": request.get("session_id"),
|
||||
"current_file": self.current_file,
|
||||
"document_generation": self.generation,
|
||||
"cutter_version": self.cutter_version,
|
||||
"identity_status": "partial",
|
||||
"completed_commands": completed_commands,
|
||||
})
|
||||
request["_event"].set()
|
||||
return
|
||||
if kind != "command":
|
||||
reply.update({"ok": False, "error": "unsupported-request-kind"})
|
||||
request["_event"].set()
|
||||
return
|
||||
if request.get("expected_generation") != self.generation:
|
||||
reply.update({
|
||||
"ok": False,
|
||||
"error": "active-document-generation-changed",
|
||||
"execution_state": "not_started",
|
||||
})
|
||||
self._record_command_completion(request_id, "not_started")
|
||||
request["_event"].set()
|
||||
return
|
||||
command = request.get("command")
|
||||
if not isinstance(command, str) or not command.strip():
|
||||
reply.update({
|
||||
"ok": False,
|
||||
"error": "invalid-command",
|
||||
"execution_state": "not_started",
|
||||
})
|
||||
self._record_command_completion(request_id, "not_started")
|
||||
request["_event"].set()
|
||||
return
|
||||
reply["execution_state"] = "running"
|
||||
try:
|
||||
output = cutter.cmdj(command) if request.get("json") is True else cutter.cmd(command)
|
||||
command_error = None
|
||||
except Exception as error:
|
||||
output = None
|
||||
command_error = str(error)
|
||||
self._refresh_document_identity()
|
||||
if command_error is None:
|
||||
reply.update({
|
||||
"ok": True,
|
||||
"output": output,
|
||||
"current_file": self.current_file,
|
||||
"document_generation": self.generation,
|
||||
"cutter_version": self.cutter_version,
|
||||
"identity_status": "partial",
|
||||
"execution_state": "complete",
|
||||
})
|
||||
self._record_command_completion(request_id, "complete")
|
||||
else:
|
||||
reply.update({
|
||||
"ok": False,
|
||||
"error": command_error,
|
||||
"current_file": self.current_file,
|
||||
"document_generation": self.generation,
|
||||
"cutter_version": self.cutter_version,
|
||||
"identity_status": "partial",
|
||||
"execution_state": "unknown",
|
||||
"message": "The command raised an exception; it may have produced partial effects. Do not retry automatically.",
|
||||
})
|
||||
self._record_command_completion(request_id, "failed")
|
||||
request["_event"].set()
|
||||
|
||||
def _record_command_completion(
|
||||
self, request_id: str, execution_state: str
|
||||
) -> None:
|
||||
with self._receipt_lock:
|
||||
if request_id in self.completed_commands or len(self.completed_commands) < MAX_COMPLETION_RECEIPTS:
|
||||
self.completed_commands[request_id] = execution_state
|
||||
|
||||
def can_accept_command(self) -> bool:
|
||||
with self._receipt_lock:
|
||||
return len(self.completed_commands) < MAX_COMPLETION_RECEIPTS
|
||||
|
||||
def _refresh_document_identity(self) -> None:
|
||||
current_file = self._read_current_file()
|
||||
if current_file != self.current_file:
|
||||
self.current_file = current_file
|
||||
self.generation += 1
|
||||
|
||||
|
||||
class _BridgeServer(threading.Thread):
|
||||
def __init__(self, dispatcher: _RequestDispatcher) -> None:
|
||||
super().__init__(name="rea-cutter-bridge", daemon=True)
|
||||
self.dispatcher = dispatcher
|
||||
self.server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
self.server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||||
self.server.bind(("127.0.0.1", 0))
|
||||
self.server.listen(8)
|
||||
self.server.settimeout(0.5)
|
||||
self.port = self.server.getsockname()[1]
|
||||
self.session_id = str(uuid.uuid4())
|
||||
self.token = secrets.token_urlsafe(32)
|
||||
self.poisoned = False
|
||||
self.connection_ordinal = 0
|
||||
self.directory = _bridge_directory()
|
||||
self.descriptor = self.directory / f"cutter-{os.getpid()}-{self.session_id}.json"
|
||||
|
||||
def publish(self) -> None:
|
||||
if os.name == "nt":
|
||||
_ensure_windows_bridge_directory(self.directory)
|
||||
else:
|
||||
_ensure_posix_bridge_directory(self.directory)
|
||||
if os.name == "nt" and (self.directory.is_symlink() or not self.directory.is_dir()):
|
||||
raise RuntimeError("Cutter bridge directory must be a real directory")
|
||||
payload = {
|
||||
"session_id": self.session_id,
|
||||
"pid": os.getpid(),
|
||||
"host": "127.0.0.1",
|
||||
"port": self.port,
|
||||
"token": self.token,
|
||||
"document_generation": self.dispatcher.generation,
|
||||
"current_file": self.dispatcher.current_file,
|
||||
"cutter_version": self.dispatcher.cutter_version,
|
||||
"identity_status": "partial",
|
||||
}
|
||||
if os.name == "nt":
|
||||
_write_windows_descriptor(self.directory, self.descriptor, json.dumps(payload).encode("utf-8"))
|
||||
return
|
||||
descriptor, temporary_path = tempfile.mkstemp(prefix=f".{self.descriptor.name}.", dir=self.directory)
|
||||
try:
|
||||
os.fchmod(descriptor, 0o600)
|
||||
descriptor_info = os.fstat(descriptor)
|
||||
if descriptor_info.st_uid != os.getuid() or stat.S_IMODE(descriptor_info.st_mode) & 0o077:
|
||||
raise RuntimeError("Cutter bridge descriptor must be owned by the current user with mode 0600")
|
||||
with os.fdopen(descriptor, "w", encoding="utf-8") as stream:
|
||||
stream.write(json.dumps(payload))
|
||||
stream.flush()
|
||||
os.fsync(stream.fileno())
|
||||
os.replace(temporary_path, self.descriptor)
|
||||
except Exception:
|
||||
try:
|
||||
os.close(descriptor)
|
||||
except OSError:
|
||||
pass
|
||||
try:
|
||||
os.unlink(temporary_path)
|
||||
except OSError:
|
||||
pass
|
||||
raise
|
||||
|
||||
def run(self) -> None:
|
||||
try:
|
||||
self.publish()
|
||||
except Exception:
|
||||
self.server.close()
|
||||
return
|
||||
while True:
|
||||
try:
|
||||
connection, _ = self.server.accept()
|
||||
except socket.timeout:
|
||||
if getattr(self, "stopping", False):
|
||||
break
|
||||
continue
|
||||
except OSError:
|
||||
break
|
||||
self.connection_ordinal += 1
|
||||
connection_ordinal = self.connection_ordinal
|
||||
with connection:
|
||||
connection.settimeout(AUTH_FRAME_TIMEOUT_SECONDS)
|
||||
authenticated = False
|
||||
try:
|
||||
hello = self._receive_frame(connection)
|
||||
if not isinstance(hello, dict) or hello.get("kind") != "rea-auth-v2" or not isinstance(hello.get("nonce"), str) or len(hello["nonce"]) != 64:
|
||||
self._send(connection, {"ok": False, "error": "authentication-failed"})
|
||||
continue
|
||||
client_nonce = hello["nonce"]
|
||||
server_nonce = f"{connection_ordinal:016x}{secrets.token_hex(24)}"
|
||||
server_proof = hmac.new(self.token.encode(), f"rea-cutter-server-v2:{self.session_id}:{client_nonce}:{server_nonce}".encode(), hashlib.sha256).hexdigest()
|
||||
self._send(connection, {"session_id": self.session_id, "nonce": server_nonce, "proof": server_proof})
|
||||
authenticated_frame = self._receive_frame(connection)
|
||||
request_bytes = authenticated_frame.get("request") if isinstance(authenticated_frame, dict) else None
|
||||
expected_proof = hmac.new(self.token.encode(), f"rea-cutter-client-v2:{self.session_id}:{client_nonce}:{server_nonce}:{request_bytes}".encode(), hashlib.sha256).hexdigest()
|
||||
if not isinstance(request_bytes, str) or not isinstance(authenticated_frame.get("proof"), str) or not hmac.compare_digest(authenticated_frame["proof"], expected_proof):
|
||||
self._send(connection, {"ok": False, "error": "authentication-failed"})
|
||||
continue
|
||||
request = json.loads(request_bytes)
|
||||
if not isinstance(request, dict) or request.get("session_id") != self.session_id:
|
||||
self._send(connection, {"ok": False, "error": "authentication-failed"})
|
||||
continue
|
||||
authenticated = True
|
||||
connection.settimeout(SOCKET_TIMEOUT_SECONDS)
|
||||
if self.poisoned and request.get("kind") not in ("status", "ack"):
|
||||
self._send_authenticated(connection, {
|
||||
"ok": False,
|
||||
"error": "session-poisoned-after-ui-timeout",
|
||||
"execution_state": "not_started",
|
||||
}, client_nonce, server_nonce)
|
||||
continue
|
||||
if (
|
||||
request.get("kind") == "command"
|
||||
and not self.dispatcher.can_accept_command()
|
||||
):
|
||||
self._send_authenticated(connection, {
|
||||
"ok": False,
|
||||
"error": "completion-receipt-capacity-exceeded",
|
||||
"execution_state": "not_started",
|
||||
}, client_nonce, server_nonce)
|
||||
continue
|
||||
request["session_id"] = self.session_id
|
||||
event = threading.Event()
|
||||
cancelled = threading.Event()
|
||||
reply: dict[str, Any] = {}
|
||||
request["_event"] = event
|
||||
request["_cancelled"] = cancelled
|
||||
self.dispatcher.dispatch.emit(request, reply)
|
||||
if not event.wait(SOCKET_TIMEOUT_SECONDS):
|
||||
cancelled.set()
|
||||
self.poisoned = True
|
||||
self._send_authenticated(connection, {
|
||||
"ok": False,
|
||||
"error": "ui-thread-request-timeout",
|
||||
"execution_state": "unknown",
|
||||
"message": "The UI operation may still complete after this timeout; restart Cutter before issuing further commands.",
|
||||
}, client_nonce, server_nonce)
|
||||
else:
|
||||
if request.get("kind") == "status" and reply.get("ok") is True:
|
||||
reply["completed_connection"] = f"{connection_ordinal - 1:016x}"
|
||||
self._send_authenticated(connection, reply, client_nonce, server_nonce)
|
||||
except socket.timeout:
|
||||
if authenticated:
|
||||
self._send_authenticated(connection, {
|
||||
"ok": False,
|
||||
"error": "request-timeout",
|
||||
"execution_state": "unknown",
|
||||
"message": "The command may have produced partial or persistent effects. Do not retry automatically.",
|
||||
}, client_nonce, server_nonce)
|
||||
except Exception as error:
|
||||
self._send(connection, {"ok": False, "error": str(error)})
|
||||
self.server.close()
|
||||
try:
|
||||
self.descriptor.unlink(missing_ok=True)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
@staticmethod
|
||||
def _receive_frame(connection: socket.socket) -> dict[str, Any]:
|
||||
data = bytearray()
|
||||
deadline = time.monotonic() + AUTH_FRAME_TIMEOUT_SECONDS
|
||||
while b"\n" not in data:
|
||||
if len(data) >= MAX_FRAME_BYTES:
|
||||
raise ValueError("frame-limit-exceeded")
|
||||
remaining = deadline - time.monotonic()
|
||||
if remaining <= 0:
|
||||
raise socket.timeout("authentication frame timed out")
|
||||
connection.settimeout(remaining)
|
||||
part = connection.recv(min(65536, MAX_FRAME_BYTES + 1 - len(data)))
|
||||
if not part:
|
||||
raise ValueError("incomplete authentication frame")
|
||||
data.extend(part)
|
||||
line, remainder = bytes(data).split(b"\n", 1)
|
||||
if len(line) > MAX_FRAME_BYTES or remainder:
|
||||
raise ValueError("invalid framed request")
|
||||
value = json.loads(line)
|
||||
return value if isinstance(value, dict) else {}
|
||||
|
||||
def _send_authenticated(self, connection: socket.socket, value: dict[str, Any], client_nonce: str, server_nonce: str) -> None:
|
||||
encoded = self._encode_authenticated_response(value, client_nonce, server_nonce)
|
||||
if encoded is None:
|
||||
value = self._truncated_response(value)
|
||||
encoded = self._encode_authenticated_response(value, client_nonce, server_nonce)
|
||||
if encoded is None:
|
||||
# This fixed-size fallback is checked through the same signed framing path.
|
||||
value = {
|
||||
"ok": False,
|
||||
"error": "response-limit-exceeded",
|
||||
"execution_state": value.get("execution_state", "unknown"),
|
||||
"output": None,
|
||||
"output_truncated": True,
|
||||
"message": "The result exceeded the bridge frame limit; command completion state is preserved.",
|
||||
}
|
||||
encoded = self._encode_authenticated_response(value, client_nonce, server_nonce)
|
||||
if encoded is None:
|
||||
return
|
||||
try:
|
||||
connection.sendall(encoded)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
def _encode_authenticated_response(self, value: dict[str, Any], client_nonce: str, server_nonce: str) -> bytes | None:
|
||||
if not _within_response_limit(value):
|
||||
return None
|
||||
prefix = b'{"response":"'
|
||||
suffix = b'","proof":"' + (b"0" * 64) + b'"}\n'
|
||||
frame_size = len(prefix) + len(suffix)
|
||||
response_parts: list[bytes] = []
|
||||
escaped_parts: list[bytes] = []
|
||||
for fragment in json.JSONEncoder(ensure_ascii=False, separators=(",", ":")).iterencode(value):
|
||||
raw = fragment.encode("utf-8")
|
||||
escaped = json.dumps(fragment, ensure_ascii=False)[1:-1].encode("utf-8")
|
||||
frame_size += len(escaped)
|
||||
if frame_size > MAX_FRAME_BYTES:
|
||||
return None
|
||||
response_parts.append(raw)
|
||||
escaped_parts.append(escaped)
|
||||
proof = hmac.new(self.token.encode(), digestmod=hashlib.sha256)
|
||||
proof.update(f"rea-cutter-response-v2:{self.session_id}:{client_nonce}:{server_nonce}:".encode())
|
||||
for part in response_parts:
|
||||
proof.update(part)
|
||||
return prefix + b"".join(escaped_parts) + b'","proof":"' + proof.hexdigest().encode() + b'"}\n'
|
||||
|
||||
@staticmethod
|
||||
def _truncated_response(value: dict[str, Any]) -> dict[str, Any]:
|
||||
response: dict[str, Any] = {
|
||||
"ok": value.get("ok") is True,
|
||||
"output": None,
|
||||
"execution_state": value.get("execution_state", "unknown"),
|
||||
"output_truncated": True,
|
||||
"message": "The command result exceeded the bridge frame limit; completion state is preserved and output was omitted.",
|
||||
}
|
||||
for key in ("current_file", "document_generation", "cutter_version", "identity_status"):
|
||||
if key in value:
|
||||
response[key] = value[key]
|
||||
if value.get("ok") is not True and isinstance(value.get("error"), str):
|
||||
response["error"] = value["error"]
|
||||
return response
|
||||
|
||||
@staticmethod
|
||||
def _send(connection: socket.socket, value: dict[str, Any]) -> None:
|
||||
if not _within_response_limit(value):
|
||||
value = {
|
||||
"ok": False,
|
||||
"error": "response-limit-exceeded",
|
||||
"execution_state": value.get("execution_state", "unknown"),
|
||||
"output_truncated": True,
|
||||
"message": "The command completed or may have completed, but its result exceeded the bridge response limit. Do not retry automatically.",
|
||||
}
|
||||
parts: list[bytes] = []
|
||||
encoded_size = 0
|
||||
for part in json.JSONEncoder(ensure_ascii=False).iterencode(value):
|
||||
encoded_part = part.encode("utf-8")
|
||||
encoded_size += len(encoded_part)
|
||||
if encoded_size > MAX_FRAME_BYTES:
|
||||
fallback = {
|
||||
"ok": False,
|
||||
"error": "response-limit-exceeded",
|
||||
"execution_state": value.get("execution_state", "unknown"),
|
||||
"output_truncated": True,
|
||||
"message": "The command completed or may have completed, but its result exceeded the bridge response limit. Do not retry automatically.",
|
||||
}
|
||||
parts = [json.dumps(fallback, separators=(",", ":")).encode("utf-8")]
|
||||
break
|
||||
parts.append(encoded_part)
|
||||
encoded = b"".join(parts)
|
||||
try:
|
||||
connection.sendall(encoded + b"\n")
|
||||
except OSError:
|
||||
# A disconnected caller must not terminate the bridge thread or
|
||||
# prevent later commands from being served.
|
||||
pass
|
||||
|
||||
def stop(self) -> None:
|
||||
self.stopping = True
|
||||
try:
|
||||
self.server.close()
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
class ReaCutterPlugin(cutter.CutterPlugin):
|
||||
name = "REA Local Bridge"
|
||||
description = "Local REA analysis bridge for the active Cutter session"
|
||||
version = "1.0"
|
||||
author = "REA"
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
self._dispatcher: _RequestDispatcher | None = None
|
||||
self._bridge: _BridgeServer | None = None
|
||||
|
||||
def setupPlugin(self) -> None:
|
||||
return
|
||||
|
||||
def setupInterface(self, main: Any) -> None:
|
||||
self._dispatcher = _RequestDispatcher()
|
||||
self._bridge = _BridgeServer(self._dispatcher)
|
||||
self._bridge.start()
|
||||
|
||||
def terminate(self) -> None:
|
||||
if self._bridge is not None:
|
||||
self._bridge.stop()
|
||||
self._bridge.join(timeout=2)
|
||||
self._bridge = None
|
||||
self._dispatcher = None
|
||||
|
||||
|
||||
def create_cutter_plugin() -> ReaCutterPlugin:
|
||||
return ReaCutterPlugin()
|
||||
|
||||
|
||||
def _within_response_limit(value: Any) -> bool:
|
||||
pending = [value]
|
||||
seen: set[int] = set()
|
||||
size = 0
|
||||
while pending:
|
||||
current = pending.pop()
|
||||
if isinstance(current, str):
|
||||
size += 2
|
||||
if current.isascii():
|
||||
size += len(current)
|
||||
size += current.count('"') + current.count("\\")
|
||||
size += sum(5 for character in current if ord(character) < 0x20)
|
||||
else:
|
||||
size += 4 * len(current)
|
||||
size += current.count('"') + current.count("\\")
|
||||
size += sum(5 for character in current if ord(character) < 0x20)
|
||||
elif isinstance(current, dict):
|
||||
identity = id(current)
|
||||
if identity in seen:
|
||||
return False
|
||||
seen.add(identity)
|
||||
size += 2 + 4 * len(current)
|
||||
if size > MAX_FRAME_BYTES:
|
||||
return False
|
||||
pending.extend(current.keys())
|
||||
pending.extend(current.values())
|
||||
elif isinstance(current, (list, tuple)):
|
||||
identity = id(current)
|
||||
if identity in seen:
|
||||
return False
|
||||
seen.add(identity)
|
||||
size += 2 + 2 * len(current)
|
||||
if size > MAX_FRAME_BYTES:
|
||||
return False
|
||||
pending.extend(current)
|
||||
else:
|
||||
size += 16
|
||||
if size > MAX_FRAME_BYTES:
|
||||
return False
|
||||
return True
|
||||
|
|
@ -38,6 +38,10 @@ graph TD
|
|||
JS_CODEC["src/javascript/sourceMaps/<br/>pinned upstream map codec<br/>owned heap-limited Node process, complete point evidence"]
|
||||
INSPECTOR["src/inspector/<br/>passive V8 Inspector discovery and capture<br/>script and target locations"]
|
||||
DOTNET["src/dotnet/<br/>Managed static metadata provider<br/>PE/CLI metadata inventory, members, IL"]
|
||||
OBJDUMP["src/objdump/<br/>GNU objdump command profile"]
|
||||
RIZIN["src/rizin/<br/>Static command profile and persistent debugger sessions"]
|
||||
GDB["src/gdb/<br/>Persistent GDB/MI sessions<br/>owned process lifecycle and target state"]
|
||||
|
||||
FIRMWARE["src/firmware/<br/>Binwalk / Unblob adapters<br/>owned processes, report normalization, private extraction"]
|
||||
ANDROID["src/android/<br/>Static APK adapter<br/>owned SDK stdio, pinned headless JADX, normalization"]
|
||||
REFERENCE["src/reference/<br/>Filesystem reference-source reader<br/>bounded traversal, validation, errors"]
|
||||
|
|
@ -50,7 +54,7 @@ graph TD
|
|||
end
|
||||
|
||||
subgraph "Contracts Layer"
|
||||
CONTRACTS["src/contracts/<br/>Caller-visible schemas for analysis, managed, browser, Electron, application, and session tools"]
|
||||
CONTRACTS["src/contracts/<br/>Caller-visible schemas for analysis, reverse engineering, managed, browser, Electron, application, and session tools"]
|
||||
ERRORS["src/contracts/errorSchemas.ts<br/>Shared analysis error schemas<br/>CLI diagnostics, generated error catalog"]
|
||||
end
|
||||
|
||||
|
|
@ -122,6 +126,14 @@ graph TD
|
|||
INSPECTOR --> DOMAIN
|
||||
APP --> DOTNET
|
||||
DOTNET --> DOMAIN
|
||||
COMPOSITION --> OBJDUMP
|
||||
OBJDUMP --> DOMAIN
|
||||
COMPOSITION --> RIZIN
|
||||
RIZIN --> DOMAIN
|
||||
CLI --> GDB
|
||||
SERVER --> GDB
|
||||
GDB --> PROCESS
|
||||
GDB --> DOMAIN
|
||||
HOPPER --> HOPPER_BRIDGE
|
||||
GHIDRA --> GHIDRA_BRIDGE
|
||||
HOPPER --> PROCESS
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@
|
|||
|
||||
**[الموقع](https://rea.tools/ar/) · [الأدلة](https://rea.tools/ar/guides/) · [دراسات الحالة](https://rea.tools/ar/showcase/)**
|
||||
|
||||
[البدء السريع](#البدء-السريع) · [كيف يعمل REA](#كيف-يعمل-rea) · [ما الذي يمكنك تحليله](#ما-الذي-يمكنك-تحليله) · [دراسات الحالة](#دراسات-الحالة) · [الأسئلة الشائعة](#الأسئلة-الشائعة) · [التوثيق](#التوثيق)
|
||||
[البدء السريع](#البدء-السريع) · [كيف يعمل REA](#كيف-يعمل-rea) · [ما الذي يمكنك تحليله](#ما-الذي-يمكنك-تحليله) · [دراسات الحالة](#دراسات-الحالة) · [الأسئلة الشائعة](#الأسئلة-الشائعة) · [أدوات GNU objdump وRizin](../reverse-engineering-tools.md) · [التوثيق](#التوثيق)
|
||||
|
||||
> [!IMPORTANT]
|
||||
> المصادر الرسمية لـ REA: [morluto/rea](https://github.com/morluto/rea), [rea.tools](https://rea.tools/), [rea-agents](https://www.npmjs.com/package/rea-agents) (npm).
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@
|
|||
|
||||
**[Website](https://rea.tools/de/) · [Anleitungen](https://rea.tools/de/guides/) · [Fallstudien](https://rea.tools/de/showcase/)**
|
||||
|
||||
[Schnellstart](#schnellstart) · [So funktioniert REA](#so-funktioniert-rea) · [Was du analysieren kannst](#was-du-analysieren-kannst) · [Fallstudien](#fallstudien) · [Häufige Fragen](#häufige-fragen) · [Dokumentation](#dokumentation)
|
||||
[Schnellstart](#schnellstart) · [So funktioniert REA](#so-funktioniert-rea) · [Was du analysieren kannst](#was-du-analysieren-kannst) · [Fallstudien](#fallstudien) · [Häufige Fragen](#häufige-fragen) · [GNU-objdump- und Rizin-Tools](../reverse-engineering-tools.md) · [Dokumentation](#dokumentation)
|
||||
|
||||
> [!IMPORTANT]
|
||||
> Offizielle Quellen für REA: [morluto/rea](https://github.com/morluto/rea), [rea.tools](https://rea.tools/), [rea-agents](https://www.npmjs.com/package/rea-agents) (npm).
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@
|
|||
|
||||
**[Sitio web](https://rea.tools/es/) · [Guías](https://rea.tools/es/guides/) · [Casos prácticos](https://rea.tools/es/showcase/)**
|
||||
|
||||
[Inicio rápido](#inicio-rápido) · [Cómo funciona REA](#cómo-funciona-rea) · [Qué puedes analizar](#qué-puedes-analizar) · [Casos prácticos](#casos-prácticos) · [Preguntas frecuentes](#preguntas-frecuentes) · [Documentación](#documentación)
|
||||
[Inicio rápido](#inicio-rápido) · [Cómo funciona REA](#cómo-funciona-rea) · [Qué puedes analizar](#qué-puedes-analizar) · [Casos prácticos](#casos-prácticos) · [Preguntas frecuentes](#preguntas-frecuentes) · [Herramientas GNU objdump y Rizin](../reverse-engineering-tools.md) · [Documentación](#documentación)
|
||||
|
||||
> [!IMPORTANT]
|
||||
> Fuentes oficiales de REA: [morluto/rea](https://github.com/morluto/rea), [rea.tools](https://rea.tools/), [rea-agents](https://www.npmjs.com/package/rea-agents) (npm).
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@
|
|||
|
||||
**[وبسایت](https://rea.tools/fa/) · [راهنماها](https://rea.tools/fa/guides/) · [نمونههای عملی](https://rea.tools/fa/showcase/)**
|
||||
|
||||
[شروع سریع](#شروع-سریع) · [REA چگونه کار میکند؟](#rea-چگونه-کار-میکند) · [چه چیزهایی را میتوان تحلیل کرد؟](#چه-چیزهایی-را-میتوان-تحلیل-کرد) · [نمونههای عملی](#نمونههای-عملی) · [پرسشهای متداول](#پرسشهای-متداول) · [مستندات](#مستندات)
|
||||
[شروع سریع](#شروع-سریع) · [REA چگونه کار میکند؟](#rea-چگونه-کار-میکند) · [چه چیزهایی را میتوان تحلیل کرد؟](#چه-چیزهایی-را-میتوان-تحلیل-کرد) · [نمونههای عملی](#نمونههای-عملی) · [پرسشهای متداول](#پرسشهای-متداول) · [ابزارهای GNU objdump و Rizin](../reverse-engineering-tools.md) · [مستندات](#مستندات)
|
||||
|
||||
> [!IMPORTANT]
|
||||
> منابع رسمی REA: [morluto/rea](https://github.com/morluto/rea), [rea.tools](https://rea.tools/), [rea-agents](https://www.npmjs.com/package/rea-agents) (npm).
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@
|
|||
|
||||
**[Site web](https://rea.tools/fr/) · [Guides](https://rea.tools/fr/guides/) · [Études de cas](https://rea.tools/fr/showcase/)**
|
||||
|
||||
[Démarrage rapide](#démarrage-rapide) · [Comment fonctionne REA](#comment-fonctionne-rea) · [Ce que vous pouvez analyser](#ce-que-vous-pouvez-analyser) · [Études de cas](#études-de-cas) · [Questions fréquentes](#questions-fréquentes) · [Documentation](#documentation)
|
||||
[Démarrage rapide](#démarrage-rapide) · [Comment fonctionne REA](#comment-fonctionne-rea) · [Ce que vous pouvez analyser](#ce-que-vous-pouvez-analyser) · [Études de cas](#études-de-cas) · [Questions fréquentes](#questions-fréquentes) · [Outils GNU objdump et Rizin](../reverse-engineering-tools.md) · [Documentation](#documentation)
|
||||
|
||||
> [!IMPORTANT]
|
||||
> Sources officielles de REA : [morluto/rea](https://github.com/morluto/rea), [rea.tools](https://rea.tools/), [rea-agents](https://www.npmjs.com/package/rea-agents) (npm).
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@
|
|||
|
||||
**[Situs Web](https://rea.tools/id/) · [Panduan](https://rea.tools/id/guides/) · [Contoh Penggunaan](https://rea.tools/id/showcase/)**
|
||||
|
||||
[Mulai Cepat](#mulai-cepat) · [Cara Kerja REA](#cara-kerja-rea) · [Hal yang Dapat Anda Analisis](#hal-yang-dapat-anda-analisis) · [Contoh Penggunaan](#contoh-penggunaan) · [FAQ](#faq) · [Dokumentasi](#dokumentasi)
|
||||
[Mulai Cepat](#mulai-cepat) · [Cara Kerja REA](#cara-kerja-rea) · [Hal yang Dapat Anda Analisis](#hal-yang-dapat-anda-analisis) · [Contoh Penggunaan](#contoh-penggunaan) · [FAQ](#faq) · [Alat GNU objdump dan Rizin](../reverse-engineering-tools.md) · [Dokumentasi](#dokumentasi)
|
||||
|
||||
> [!IMPORTANT]
|
||||
> Sumber resmi REA: [morluto/rea](https://github.com/morluto/rea), [rea.tools](https://rea.tools/), [rea-agents](https://www.npmjs.com/package/rea-agents) (npm).
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@
|
|||
|
||||
**[Sito web](https://rea.tools/it/) · [Guide](https://rea.tools/it/guides/) · [Showcase](https://rea.tools/it/showcase/)**
|
||||
|
||||
[Avvio rapido](#avvio-rapido) · [Come funziona REA](#come-funziona-rea) · [Cosa puoi analizzare](#cosa-puoi-analizzare) · [Showcase](#showcase) · [FAQ](#faq) · [Documentazione](#documentazione)
|
||||
[Avvio rapido](#avvio-rapido) · [Come funziona REA](#come-funziona-rea) · [Cosa puoi analizzare](#cosa-puoi-analizzare) · [Showcase](#showcase) · [FAQ](#faq) · [Strumenti GNU objdump e Rizin](../reverse-engineering-tools.md) · [Documentazione](#documentazione)
|
||||
|
||||
> [!IMPORTANT]
|
||||
> Fonti ufficiali di REA: [morluto/rea](https://github.com/morluto/rea), [rea.tools](https://rea.tools/), [rea-agents](https://www.npmjs.com/package/rea-agents) (npm).
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@
|
|||
|
||||
**[ウェブサイト](https://rea.tools/ja/) · [ガイド](https://rea.tools/ja/guides/) · [事例](https://rea.tools/ja/showcase/)**
|
||||
|
||||
[クイックスタート](#クイックスタート) · [REA の仕組み](#rea-の仕組み) · [分析できる対象](#分析できる対象) · [事例](#事例) · [よくある質問](#よくある質問) · [ドキュメント](#ドキュメント)
|
||||
[クイックスタート](#クイックスタート) · [REA の仕組み](#rea-の仕組み) · [分析できる対象](#分析できる対象) · [事例](#事例) · [よくある質問](#よくある質問) · [GNU objdump・Rizin ツール](../reverse-engineering-tools.md) · [ドキュメント](#ドキュメント)
|
||||
|
||||
> [!IMPORTANT]
|
||||
> REA の公式配布元: [morluto/rea](https://github.com/morluto/rea), [rea.tools](https://rea.tools/), [rea-agents](https://www.npmjs.com/package/rea-agents) (npm).
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@
|
|||
|
||||
**[웹사이트](https://rea.tools/ko/) · [가이드](https://rea.tools/ko/guides/) · [사례](https://rea.tools/ko/showcase/)**
|
||||
|
||||
[빠른 시작](#빠른-시작) · [REA의 작동 방식](#rea의-작동-방식) · [분석할 수 있는 대상](#분석할-수-있는-대상) · [사례](#사례) · [자주 묻는 질문](#자주-묻는-질문) · [문서](#문서)
|
||||
[빠른 시작](#빠른-시작) · [REA의 작동 방식](#rea의-작동-방식) · [분석할 수 있는 대상](#분석할-수-있는-대상) · [사례](#사례) · [자주 묻는 질문](#자주-묻는-질문) · [GNU objdump 및 Rizin 도구](../reverse-engineering-tools.md) · [문서](#문서)
|
||||
|
||||
> [!IMPORTANT]
|
||||
> REA 공식 배포처: [morluto/rea](https://github.com/morluto/rea), [rea.tools](https://rea.tools/), [rea-agents](https://www.npmjs.com/package/rea-agents) (npm).
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@
|
|||
|
||||
**[Strona internetowa](https://rea.tools/pl/) · [Przewodniki](https://rea.tools/pl/guides/) · [Przykłady zastosowań](https://rea.tools/pl/showcase/)**
|
||||
|
||||
[Szybki start](#szybki-start) · [Jak działa REA](#jak-działa-rea) · [Co możesz analizować](#co-możesz-analizować) · [Przykłady zastosowań](#przykłady-zastosowań) · [FAQ](#faq) · [Dokumentacja](#dokumentacja)
|
||||
[Szybki start](#szybki-start) · [Jak działa REA](#jak-działa-rea) · [Co możesz analizować](#co-możesz-analizować) · [Przykłady zastosowań](#przykłady-zastosowań) · [FAQ](#faq) · [Narzędzia GNU objdump i Rizin](../reverse-engineering-tools.md) · [Dokumentacja](#dokumentacja)
|
||||
|
||||
> [!IMPORTANT]
|
||||
> Oficjalne źródła REA: [morluto/rea](https://github.com/morluto/rea), [rea.tools](https://rea.tools/), [rea-agents](https://www.npmjs.com/package/rea-agents) (npm).
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@
|
|||
|
||||
**[Site](https://rea.tools/pt-BR/) · [Guias](https://rea.tools/pt-BR/guides/) · [Casos de uso](https://rea.tools/pt-BR/showcase/)**
|
||||
|
||||
[Início rápido](#início-rápido) · [Como o REA funciona](#como-o-rea-funciona) · [O que você pode analisar](#o-que-você-pode-analisar) · [Casos de uso](#casos-de-uso) · [Perguntas frequentes](#perguntas-frequentes) · [Documentação](#documentação)
|
||||
[Início rápido](#início-rápido) · [Como o REA funciona](#como-o-rea-funciona) · [O que você pode analisar](#o-que-você-pode-analisar) · [Casos de uso](#casos-de-uso) · [Perguntas frequentes](#perguntas-frequentes) · [Ferramentas GNU objdump e Rizin](../reverse-engineering-tools.md) · [Documentação](#documentação)
|
||||
|
||||
> [!IMPORTANT]
|
||||
> Fontes oficiais do REA: [morluto/rea](https://github.com/morluto/rea), [rea.tools](https://rea.tools/), [rea-agents](https://www.npmjs.com/package/rea-agents) (npm).
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@
|
|||
|
||||
**[Сайт](https://rea.tools/ru/) · [Руководства](https://rea.tools/ru/guides/) · [Примеры](https://rea.tools/ru/showcase/)**
|
||||
|
||||
[Быстрый старт](#быстрый-старт) · [Как работает REA](#как-работает-rea) · [Что можно анализировать](#что-можно-анализировать) · [Примеры](#примеры) · [Частые вопросы](#частые-вопросы) · [Документация](#документация)
|
||||
[Быстрый старт](#быстрый-старт) · [Как работает REA](#как-работает-rea) · [Что можно анализировать](#что-можно-анализировать) · [Примеры](#примеры) · [Частые вопросы](#частые-вопросы) · [Инструменты GNU objdump и Rizin](../reverse-engineering-tools.md) · [Документация](#документация)
|
||||
|
||||
> [!IMPORTANT]
|
||||
> Официальные источники REA: [morluto/rea](https://github.com/morluto/rea), [rea.tools](https://rea.tools/), [rea-agents](https://www.npmjs.com/package/rea-agents) (npm).
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@
|
|||
|
||||
**[เว็บไซต์](https://rea.tools/th/) · [คู่มือ](https://rea.tools/th/guides/) · [ตัวอย่างการใช้งาน](https://rea.tools/th/showcase/)**
|
||||
|
||||
[เริ่มต้นอย่างรวดเร็ว](#เริ่มต้นอย่างรวดเร็ว) · [REA ทำงานอย่างไร](#rea-ทำงานอย่างไร) · [สิ่งที่วิเคราะห์ได้](#สิ่งที่วิเคราะห์ได้) · [ตัวอย่างการใช้งาน](#ตัวอย่างการใช้งาน) · [คำถามที่พบบ่อย](#คำถามที่พบบ่อย) · [เอกสาร](#เอกสาร)
|
||||
[เริ่มต้นอย่างรวดเร็ว](#เริ่มต้นอย่างรวดเร็ว) · [REA ทำงานอย่างไร](#rea-ทำงานอย่างไร) · [สิ่งที่วิเคราะห์ได้](#สิ่งที่วิเคราะห์ได้) · [ตัวอย่างการใช้งาน](#ตัวอย่างการใช้งาน) · [คำถามที่พบบ่อย](#คำถามที่พบบ่อย) · [เครื่องมือ GNU objdump และ Rizin](../reverse-engineering-tools.md) · [เอกสาร](#เอกสาร)
|
||||
|
||||
> [!IMPORTANT]
|
||||
> แหล่งทางการของ REA: [morluto/rea](https://github.com/morluto/rea), [rea.tools](https://rea.tools/), [rea-agents](https://www.npmjs.com/package/rea-agents) (npm).
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@
|
|||
|
||||
**[Web sitesi](https://rea.tools/tr/) · [Rehberler](https://rea.tools/tr/guides/) · [Örnek çalışmalar](https://rea.tools/tr/showcase/)**
|
||||
|
||||
[Hızlı başlangıç](#hızlı-başlangıç) · [REA nasıl çalışır](#rea-nasıl-çalışır) · [Neleri analiz edebilirsiniz](#neleri-analiz-edebilirsiniz) · [Örnek çalışmalar](#örnek-çalışmalar) · [Sık sorulan sorular](#sık-sorulan-sorular) · [Belgeler](#belgeler)
|
||||
[Hızlı başlangıç](#hızlı-başlangıç) · [REA nasıl çalışır](#rea-nasıl-çalışır) · [Neleri analiz edebilirsiniz](#neleri-analiz-edebilirsiniz) · [Örnek çalışmalar](#örnek-çalışmalar) · [Sık sorulan sorular](#sık-sorulan-sorular) · [GNU objdump ve Rizin araçları](../reverse-engineering-tools.md) · [Belgeler](#belgeler)
|
||||
|
||||
> [!IMPORTANT]
|
||||
> REA’nın resmî kaynakları: [morluto/rea](https://github.com/morluto/rea), [rea.tools](https://rea.tools/), [rea-agents](https://www.npmjs.com/package/rea-agents) (npm).
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@
|
|||
|
||||
**[Сайт](https://rea.tools/uk/) · [Посібники](https://rea.tools/uk/guides/) · [Приклади](https://rea.tools/uk/showcase/)**
|
||||
|
||||
[Швидкий старт](#швидкий-старт) · [Як працює REA](#як-працює-rea) · [Що можна аналізувати](#що-можна-аналізувати) · [Приклади](#приклади) · [Поширені запитання](#поширені-запитання) · [Документація](#документація)
|
||||
[Швидкий старт](#швидкий-старт) · [Як працює REA](#як-працює-rea) · [Що можна аналізувати](#що-можна-аналізувати) · [Приклади](#приклади) · [Поширені запитання](#поширені-запитання) · [Інструменти GNU objdump і Rizin](../reverse-engineering-tools.md) · [Документація](#документація)
|
||||
|
||||
> [!IMPORTANT]
|
||||
> Офіційні джерела REA: [morluto/rea](https://github.com/morluto/rea), [rea.tools](https://rea.tools/), [rea-agents](https://www.npmjs.com/package/rea-agents) (npm).
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@
|
|||
|
||||
**[Trang web](https://rea.tools/vi/) · [Hướng dẫn](https://rea.tools/vi/guides/) · [Các nghiên cứu điển hình](https://rea.tools/vi/showcase/)**
|
||||
|
||||
[Bắt đầu nhanh](#bắt-đầu-nhanh) · [Cách REA hoạt động](#cách-rea-hoạt-động) · [Những gì bạn có thể phân tích](#những-gì-bạn-có-thể-phân-tích) · [Các nghiên cứu điển hình](#các-nghiên-cứu-điển-hình) · [Câu hỏi thường gặp](#câu-hỏi-thường-gặp) · [Tài liệu](#tài-liệu)
|
||||
[Bắt đầu nhanh](#bắt-đầu-nhanh) · [Cách REA hoạt động](#cách-rea-hoạt-động) · [Những gì bạn có thể phân tích](#những-gì-bạn-có-thể-phân-tích) · [Các nghiên cứu điển hình](#các-nghiên-cứu-điển-hình) · [Câu hỏi thường gặp](#câu-hỏi-thường-gặp) · [Công cụ GNU objdump và Rizin](../reverse-engineering-tools.md) · [Tài liệu](#tài-liệu)
|
||||
|
||||
> [!IMPORTANT]
|
||||
> Các nguồn chính thức của REA: [morluto/rea](https://github.com/morluto/rea), [rea.tools](https://rea.tools/), [rea-agents](https://www.npmjs.com/package/rea-agents) (npm).
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@
|
|||
|
||||
**[網站](https://rea.tools/zh-TW/) · [指南](https://rea.tools/zh-TW/guides/) · [案例](https://rea.tools/zh-TW/showcase/)**
|
||||
|
||||
[快速開始](#快速開始) · [REA 的工作原理](#rea-的工作原理) · [可以分析什麼](#可以分析什麼) · [案例](#案例) · [常見問題](#常見問題) · [文件](#文件)
|
||||
[快速開始](#快速開始) · [REA 的工作原理](#rea-的工作原理) · [可以分析什麼](#可以分析什麼) · [案例](#案例) · [常見問題](#常見問題) · [GNU objdump 與 Rizin 工具](../reverse-engineering-tools.md) · [文件](#文件)
|
||||
|
||||
> [!IMPORTANT]
|
||||
> REA 官方來源: [morluto/rea](https://github.com/morluto/rea), [rea.tools](https://rea.tools/), [rea-agents](https://www.npmjs.com/package/rea-agents) (npm).
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@
|
|||
|
||||
**[网站](https://rea.tools/zh-CN/) · [指南](https://rea.tools/zh-CN/guides/) · [案例](https://rea.tools/zh-CN/showcase/)**
|
||||
|
||||
[快速开始](#快速开始) · [REA 的工作原理](#rea-的工作原理) · [可以分析什么](#可以分析什么) · [案例](#案例) · [常见问题](#常见问题) · [文档](#文档)
|
||||
[快速开始](#快速开始) · [REA 的工作原理](#rea-的工作原理) · [可以分析什么](#可以分析什么) · [案例](#案例) · [常见问题](#常见问题) · [GNU objdump 和 Rizin 工具](../reverse-engineering-tools.md) · [文档](#文档)
|
||||
|
||||
> [!IMPORTANT]
|
||||
> REA 官方来源: [morluto/rea](https://github.com/morluto/rea), [rea.tools](https://rea.tools/), [rea-agents](https://www.npmjs.com/package/rea-agents) (npm).
|
||||
|
|
|
|||
|
|
@ -0,0 +1,81 @@
|
|||
# Reverse-engineering tool integrations
|
||||
|
||||
REA can call caller-supplied GNU objdump, GDB, and Rizin installations. REA does not install or update these tools. Static objdump/Rizin workflows use the same services from CLI and MCP. GDB's persistent MCP sessions and ephemeral CLI command are available only on Linux. Persistent debugger sessions exist only for the lifetime of the REA MCP server process.
|
||||
|
||||
In a shared MCP server, unrestricted Rizin and Cutter commands, GDB console
|
||||
commands, Rizin debugger start/close, and GDB debugger close retire any active
|
||||
binary analysis session after the operation. This prevents a fresh analysis
|
||||
provider from being rebound automatically while command effects may still be
|
||||
writing. After the command and any background writers have stopped, call
|
||||
`open_binary` explicitly before continuing analysis. A successful command
|
||||
response alone does not prove that background writers have stopped. An active
|
||||
Ghidra analysis session must be closed explicitly before an external mutation
|
||||
can run.
|
||||
|
||||
## GNU objdump
|
||||
|
||||
`inspect_with_objdump` and `rea inspect-with-objdump` capture stdout and stderr as inline Evidence. Artifact inspection Evidence records SHA-256 digests before and after the provider call and whether those observed digests differ; this cannot prove the exact bytes consumed if the artifact changes during execution. Choose one artifact operation: `file_headers`, `section_headers`, `symbols`, `relocations`, `dwarf`, `disassemble`, or `disassemble_all`. `disassemble` maps to `-d`; `disassemble_all` maps to `-D` and has different symbol-boundary semantics. The pathless `inspect_with_objdump {"operation":"architectures"}` MCP call or `rea list-objdump-architectures` CLI command maps to `-i` and produces subjectless provider-capability Evidence describing BFD targets compiled into the installed binary, not a guarantee that a target is supported.
|
||||
|
||||
For DWARF, REA does not follow local debug links by default. Set `follow_debug_links` to `true` to pass `-WK`; external debug-file identities are not bound to Evidence when following links is enabled. The CLI option is `--follow-debug-links true`. REA clears `DEBUGINFOD_URLS` in the objdump child environment to disable debuginfod network access on GNU binutils versions that do not support `-WE`. The complete raw output is authoritative; REA does not normalize it into a version-independent disassembly schema.
|
||||
|
||||
```sh
|
||||
rea inspect-with-objdump ./program disassemble
|
||||
rea inspect-with-objdump ./program dwarf --follow-debug-links false
|
||||
```
|
||||
|
||||
Captured stdout and stderr are retained up to a 16 MiB combined limit; `output_truncated` marks output beyond that limit.
|
||||
|
||||
## GDB
|
||||
|
||||
Persistent GDB sessions, including the MCP workflow and one-shot `rea debug-with-gdb` CLI, are available only on Linux. REA verifies a live inferior's output descriptors through `/proc` before detaching it; it does not claim equivalent descriptor verification on macOS. Windows is also unsupported because owned Job Object cleanup can terminate debugger inferiors. On supported Linux hosts, `start_gdb_session`, `gdb_console`, `gdb_session_status`, and `close_gdb_session` manage a persistent GDB/MI session in MCP. The one-shot CLI requires a caller-selected durable TTY: `rea debug-with-gdb --inferior-tty /dev/pts/7 'file ./program'`. The TTY must remain available after GDB exits so a detached inferior can continue writing. MCP starts GDB with `--nx --quiet --interpreter=mi3`, retries initialization with MI2 if MI3 initialization returns an error and cleanup succeeds, waits for its prompt, and disables auto-loading. Pass optional `inferior_tty` to select the terminal that future inferior runs use; REA configures it before the session becomes ready and checks the exact value with GDB/MI. `gdb_session_status` reports the selected path, whether REA still considers the configuration verified, and whether invalid UTF-8 has been observed in MI bytes (`mi_invalid_utf8`) or stderr (`stderr_invalid_utf8`). Commands that can change the inferior TTY or redirect a run revoke that verification permanently for the session, so close retains GDB ownership if a live inferior remains. Closing a session with one live inferior and a verified TTY automatically detaches the target, which can resume execution; MCP clients receive destructive-operation annotations for this behavior. The console remains unrestricted and can run shell commands, change target state, access files, and use network in the REA process user's security context. Raw MI records and console/target/log streams are returned with Evidence; command output is retained up to 16 MiB and explicitly marked when truncated.
|
||||
|
||||
Before a normal close, REA checks for live GDB thread groups. If one remains and a durable `inferior_tty` is verified, REA asks GDB to detach it and confirms the detach before stopping the debugger. Without that sink, select and apply a disposition to each inferior through `gdb_console` (for example, detach or kill as appropriate), then retry close. REA never infers target ownership from a PID or path. When the MCP server shuts down, REA terminates only its directly owned GDB process after confirming no inferior or a sink-backed detach; otherwise it retains the process and its output streams for retry.
|
||||
|
||||
```text
|
||||
start_gdb_session {"inferior_tty":"/dev/pts/7"}
|
||||
gdb_console {"session_id":"…","command":"file ./program"}
|
||||
gdb_console {"session_id":"…","command":"info files"}
|
||||
gdb_session_status {"session_id":"…"}
|
||||
close_gdb_session {"session_id":"…"}
|
||||
```
|
||||
|
||||
## Rizin
|
||||
|
||||
`start_rizin_debug_session` and `rea debug-with-rizin` require an explicit `inferior_tty` on Linux. Rizin launches the target with stdout and stderr routed to that caller-selected TTY and `dbg.exitkills=false`; the target can therefore continue running and writing to the TTY after REA closes the debugger. This is a visible target output effect, and the caller is responsible for selecting a suitable terminal that remains available. Before closing, REA serializes after the active command, parses the structured `olj` descriptor list, detaches only a unique `ptrace://` descriptor, confirms its absence, reads back `dbg.exitkills=false`, and verifies `/proc/<pid>/fd/1` and `/proc/<pid>/fd/2` still identify the selected TTY by device and inode. Any malformed or ambiguous readback keeps the Rizin session owned for retry. Persistent Rizin debugger sessions are unavailable outside Linux because this verification depends on `/proc`.
|
||||
|
||||
Persistent command Evidence retains the caller-selected and resolved startup paths in `parameters.launch_provenance`. These paths describe launch provenance, not the current target after unrestricted commands. `rizin_debug_session_status.output_invalid_utf8` reports whether lossy frame decoding has been observed during the session. Command Evidence sets `output_invalid_utf8` only when loss was observed in frames collected for that command, including retained partial output; earlier startup or command frames do not mark later valid output as lossy.
|
||||
|
||||
```sh
|
||||
rea debug-with-rizin --inferior-tty /dev/pts/7 ./program dr
|
||||
```
|
||||
|
||||
`execute_rizin_command` and `rea execute-rizin-command` run one selected command against a local artifact. The one-shot profile uses `-N -q -c`, suppressing user settings and scripts while leaving plugins available. Commands and plugins run with the REA process user's security context and may access files, the network, or perform other actions. REA preserves raw output and does not claim that command-specific JSON has one universal schema.
|
||||
|
||||
```sh
|
||||
rea execute-rizin-command ./program iI
|
||||
```
|
||||
|
||||
Captured stdout and stderr are retained up to a 16 MiB combined limit; `output_truncated` marks output beyond that limit.
|
||||
|
||||
## Cutter bridge
|
||||
|
||||
Cutter commands use the local REA Python bridge and authenticated loopback messages.
|
||||
The client and plugin must support the current v2 handshake; older peers are
|
||||
rejected before dispatch because they cannot preserve cross-caller recovery proof.
|
||||
The client checks the final encoded request against the bridge's 16 MiB frame
|
||||
limit before dispatch; an oversized command does not retain a cleanup owner.
|
||||
Discovery admits at most 256 descriptor candidates and reads and probes at most
|
||||
eight concurrently. A larger directory reports `bridge_directory_capacity_exceeded`.
|
||||
|
||||
Terminal receipts are recovered before another command, including from a new CLI
|
||||
invocation. A signed status response also confirms completion of older bridge
|
||||
connections after its UI dispatch, preserving recovery proof for a caller whose
|
||||
receipt another caller has acknowledged. Neither receipt recovery nor a status
|
||||
probe retries the original command. Failed acknowledgements remain pending and
|
||||
block new commands until they can be retried successfully.
|
||||
|
||||
## Configuration and coverage
|
||||
|
||||
The optional `REA_OBJDUMP_COMMAND`, `REA_GDB_COMMAND`, and `REA_RIZIN_COMMAND` values select executable names or paths; otherwise REA searches `PATH`. Values are passed directly to process spawning, not expanded as shell command lines. Provider versions and capabilities remain unknown until observed from the installed tool and verified in the corresponding real-provider lane.
|
||||
|
||||
Provider capture limits are separate from the MCP response budget. A result that fits the provider limit may still exceed the default 10 MiB MCP response budget and return a transport `resource_constraint`. Configure `REA_MCP_MAX_RESPONSE_BYTES` to match a larger client receive buffer when that client supports one; otherwise use retained Evidence/export or complete CLI JSON output. See [MCP response limits](./mcp-contracts.md#tool-results).
|
||||
|
|
@ -262,6 +262,24 @@ CI owns coverage; aggregate and domain/contract thresholds are maintained in
|
|||
`vitest.config.ts`. They are glob-specific and never updated automatically.
|
||||
Coverage does not replace named boundary or real-provider scenarios.
|
||||
|
||||
The `reverse-tools` commands run the focused tests and report coverage and
|
||||
mutation results without an additional percentage requirement. Test failures,
|
||||
invalid mutation configuration, and runner failures still fail the check.
|
||||
Mutation targets cover reviewed production behavior, including signal-terminated
|
||||
commands, Cutter descriptor identity, debugger cleanup ownership, GDB command
|
||||
exit and cancellation, and Rizin startup, timeout Evidence, and process exit.
|
||||
A second exhaustive pass runs the focused Cutter and Rizin timeout semantics tests against every compilable
|
||||
selected mutant to avoid relying on per-test mutation coverage associations;
|
||||
Stryker reports compile-error mutants separately because those cannot execute
|
||||
tests. Both passes resolve named syntax owners, scoped declarations, guards,
|
||||
callbacks, and reviewed expressions from the current TypeScript source; no
|
||||
hand-maintained line ranges select mutations. Missing, ambiguous, or malformed
|
||||
source targets stop configuration before mutation generation. The selections
|
||||
retain their review scope rather than expanding to whole source files. The
|
||||
focused suite also exercises affected MCP routes and the Python Cutter bridge.
|
||||
Contract, integration, and real-provider lanes complement these focused tests;
|
||||
simulated providers do not establish real-provider behavior.
|
||||
|
||||
CI runs six native Vitest shards without retries. Each shard emits a blob
|
||||
report; the merge job produces JUnit and JSON diagnostics, a module timing
|
||||
report, plus aggregate coverage during full qualification. It retains available
|
||||
|
|
@ -346,6 +364,17 @@ that explicit lane, not the routine iteration requirement.
|
|||
|
||||
## Real-toolchain verification lanes
|
||||
|
||||
Linux Rizin debugger ownership changes require a separate real-provider gate on
|
||||
Rizin 0.7.4. Run
|
||||
`npm run test:focused -- tests/boundary/providers/RizinInferiorTty.test.ts`.
|
||||
This test verifies the caller-selected TTY against the actual inferior's file
|
||||
descriptors, confirms `dbg.exitkills=false`, detaches through the `olj`/`o-`
|
||||
protocol, and checks that the target survives debugger close and writes to the
|
||||
selected TTY. The suite is included in the reverse-tools Vitest and mutation
|
||||
typecheck selections; its real-provider assertions skip when Rizin 0.7.4 is
|
||||
unavailable. Run the focused command above on a host with that provider for the
|
||||
explicit real-provider verification gate.
|
||||
|
||||
Each real-toolchain command must require only the host tools needed to prove
|
||||
its stated claim. Use a host-native fixture for host/provider acceptance, and
|
||||
place optional cross-target formats or platform-specific runners in separate
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@
|
|||
"src/application/RealHopperAssertions.ts",
|
||||
"scripts/verify/fridaBindingsCompatibility.ts",
|
||||
"scripts/verify/termux/browser.mjs",
|
||||
"scripts/verify-real-cutter.mjs",
|
||||
"scripts/verify-real-ghidra-noreturn.mjs",
|
||||
"tests/processOwnershipRunner.ts"
|
||||
],
|
||||
|
|
@ -17,7 +18,12 @@
|
|||
"entry": ["docs/.vitepress/config.ts"]
|
||||
},
|
||||
"ignoreBinaries": ["mkfifo"],
|
||||
"ignoreDependencies": ["electron", "esbuild", "webpack"],
|
||||
"ignoreDependencies": [
|
||||
"@stryker-mutator/command-runner",
|
||||
"electron",
|
||||
"esbuild",
|
||||
"webpack"
|
||||
],
|
||||
"ignoreExportsUsedInFile": true,
|
||||
"rules": {
|
||||
"files": "error",
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -1,12 +1,12 @@
|
|||
REA third-party npm runtime copyright, license and NOTICE texts
|
||||
Collected from integrity-verified locked npm archives. Release-specific supplements and upstream notice gaps are identified in their entries.
|
||||
External engines are separately obtained; see THIRD_PARTY_NOTICES.md.
|
||||
Runtime dependency fingerprint (SHA-256): 63d5b55e027a0cfb3e77989ca337aca905f1652d10cfbfc4d4dd876d3ecd4f3a
|
||||
Runtime dependency fingerprint (SHA-256): b0d0151958456c6e72c5720af80d783893be88a7b4c9a409db8505296fe10bd9
|
||||
|
||||
===== @babel/helper-string-parser@8.0.0 =====
|
||||
===== @babel/helper-string-parser@8.0.6 =====
|
||||
Declared license: MIT
|
||||
Archive: https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-8.0.0.tgz
|
||||
Integrity: sha512-6mJgmFFFIIO82vvoLt9XtRC7/TkzXfts1t/SpRX4IHSzMgqoPYCWesVu1udUPUWioAE/2fcG6WuI8zrkE1gwrg==
|
||||
Archive: https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-8.0.6.tgz
|
||||
Integrity: sha512-+6HYEBnsDRZHJtUTAEfMQd0zMDqdFHS7C+5vAsEZqBUr3vEMWOWlHJ0Jz1gS8jJIlCvLsuA6ivmeoqzH936YcA==
|
||||
Scope: npm runtime dependency, including optional platform packages.
|
||||
|
||||
--- package/LICENSE ---
|
||||
|
|
@ -34,11 +34,10 @@ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
|
|||
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
|
||||
|
||||
===== @babel/helper-validator-identifier@8.0.4 =====
|
||||
===== @babel/helper-validator-identifier@8.0.6 =====
|
||||
Declared license: MIT
|
||||
Archive: https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-8.0.4.tgz
|
||||
Integrity: sha512-4wFaiLd0bVo4cIoTXI3zKI038NIWE/cr3jvBjejOVYVxV/m8Ltav1USiGzG1fmS5J2RhgEOgXNNK46cRPnRsrg==
|
||||
Archive: https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-8.0.6.tgz
|
||||
Integrity: sha512-HYx0YmuchRxRSpnjvcNffnPS8DCauKNmnumIuHG3jnQAVewRA7kfJGwF13ayEpSZs7s4R/oBH4eppuXcAQd5JQ==
|
||||
Scope: npm runtime dependency, including optional platform packages.
|
||||
|
||||
--- package/LICENSE ---
|
||||
|
|
@ -66,7 +65,6 @@ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
|
|||
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
|
||||
|
||||
===== @babel/parser@8.0.4 =====
|
||||
Declared license: MIT
|
||||
Archive: https://registry.npmjs.org/@babel/parser/-/parser-8.0.4.tgz
|
||||
|
|
@ -8443,5 +8441,3 @@ HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
|||
WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
|
||||
OTHER DEALINGS IN THE SOFTWARE.
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -123,7 +123,8 @@ napi_value wrap(napi_env env, std::unique_ptr<Resource> resource);
|
|||
Resource& resource(napi_env env, napi_value value, Kind kind);
|
||||
|
||||
std::unique_ptr<File> openFile(const std::wstring& path, DWORD access = GENERIC_READ,
|
||||
bool directory = false);
|
||||
bool directory = false,
|
||||
DWORD finalSharing = FILE_SHARE_READ);
|
||||
napi_value identity(napi_env env, HANDLE handle, const std::wstring& path);
|
||||
std::unique_ptr<Runtime> createRuntime(const std::wstring& parent, const std::wstring& prefix);
|
||||
void closeRuntime(Runtime& runtime);
|
||||
|
|
|
|||
|
|
@ -61,7 +61,8 @@ static Handle openComponent(const std::wstring& path, DWORD access, bool directo
|
|||
return handle;
|
||||
}
|
||||
|
||||
std::unique_ptr<File> openFile(const std::wstring& requested, DWORD access, bool directory) {
|
||||
std::unique_ptr<File> openFile(const std::wstring& requested, DWORD access, bool directory,
|
||||
DWORD finalSharing) {
|
||||
auto result = std::make_unique<File>(localPath(requested));
|
||||
result->requestedPath = requested;
|
||||
const auto& path = result->path;
|
||||
|
|
@ -77,7 +78,8 @@ std::unique_ptr<File> openFile(const std::wstring& requested, DWORD access, bool
|
|||
while (offset < path.size()) {
|
||||
auto end = path.find(L'\\', offset);
|
||||
const bool final = end == std::wstring::npos;
|
||||
auto child = openComponent(path.substr(0, end), final ? access : 0, !final || directory);
|
||||
auto child = openComponent(path.substr(0, end), final ? access : 0, !final || directory,
|
||||
final ? finalSharing : FILE_SHARE_READ);
|
||||
// Once the next component is pinned against deletion, its parent cannot
|
||||
// become empty and NTFS cannot convert that parent into a reparse point.
|
||||
// Relax only ancestor write sharing so unrelated child renames can proceed.
|
||||
|
|
@ -238,6 +240,100 @@ static void verifyPrivate(HANDLE handle, const std::wstring& path, bool root) {
|
|||
require(user && system, "Private DACL must allow only current user and SYSTEM", path, ERROR_ACCESS_DENIED);
|
||||
}
|
||||
|
||||
static std::unique_ptr<File> openPrivateBridgeDirectory(const std::wstring& path) {
|
||||
// The retained runtime root handle carries DELETE authority for owned
|
||||
// cleanup. Windows checks sharing in both directions, so this read-only
|
||||
// inspection handle must share DELETE even though it requests no such right.
|
||||
auto directory = openFile(path, FILE_LIST_DIRECTORY, true,
|
||||
FILE_SHARE_READ | FILE_SHARE_DELETE);
|
||||
verifyPrivate(directory->get(), path, true);
|
||||
return directory;
|
||||
}
|
||||
|
||||
static Handle openPrivateBridgeDescriptor(HANDLE parent, const std::wstring& name,
|
||||
const std::wstring& path) {
|
||||
require(!name.empty() && name.find_first_of(L"\\/:\"<>|?*") == std::wstring::npos &&
|
||||
name != L"." && name != L".." && name.size() <= 255,
|
||||
"Private descriptor requires one filename component", path, ERROR_INVALID_PARAMETER);
|
||||
UNICODE_STRING coordinate{};
|
||||
coordinate.Buffer = const_cast<wchar_t*>(name.data());
|
||||
coordinate.Length = static_cast<USHORT>(name.size() * sizeof(wchar_t));
|
||||
coordinate.MaximumLength = coordinate.Length;
|
||||
OBJECT_ATTRIBUTES attributes;
|
||||
InitializeObjectAttributes(&attributes, &coordinate, OBJ_CASE_INSENSITIVE, parent, nullptr);
|
||||
IO_STATUS_BLOCK observation{};
|
||||
HANDLE raw = INVALID_HANDLE_VALUE;
|
||||
// Immutable runtime files retain their original writer handle so later
|
||||
// opens cannot acquire write or delete access. This read-only handle must
|
||||
// share those granted rights for Windows' symmetric share check to pass.
|
||||
const auto status = NtCreateFile(&raw, GENERIC_READ | READ_CONTROL | FILE_READ_ATTRIBUTES | SYNCHRONIZE,
|
||||
&attributes, &observation, nullptr, FILE_ATTRIBUTE_NORMAL,
|
||||
FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE, FILE_OPEN,
|
||||
FILE_SYNCHRONOUS_IO_NONALERT | FILE_OPEN_REPARSE_POINT |
|
||||
FILE_NON_DIRECTORY_FILE,
|
||||
nullptr, 0);
|
||||
if (status < 0)
|
||||
throw Failure("Open Cutter bridge descriptor relative to its verified directory failed", path,
|
||||
RtlNtStatusToDosError(status));
|
||||
Handle descriptor(raw);
|
||||
require(descriptor.valid() && observation.Information == FILE_OPENED,
|
||||
"Cutter bridge descriptor was not opened as an existing file", path, ERROR_ACCESS_DENIED);
|
||||
FILE_STANDARD_INFO standard{};
|
||||
require(GetFileInformationByHandleEx(descriptor.get(), FileStandardInfo, &standard, sizeof(standard)),
|
||||
"Read Cutter bridge descriptor metadata failed", path);
|
||||
require(!standard.Directory && standard.EndOfFile.QuadPart >= 0 &&
|
||||
standard.EndOfFile.QuadPart <= 64 * 1024,
|
||||
"Cutter bridge descriptor exceeds its byte limit or is not a file", path, ERROR_FILE_TOO_LARGE);
|
||||
FILE_ATTRIBUTE_TAG_INFO fileAttributes{};
|
||||
require(GetFileInformationByHandleEx(descriptor.get(), FileAttributeTagInfo, &fileAttributes, sizeof(fileAttributes)),
|
||||
"Read Cutter bridge descriptor attributes failed", path);
|
||||
require((fileAttributes.FileAttributes & FILE_ATTRIBUTE_REPARSE_POINT) == 0,
|
||||
"Cutter bridge descriptor cannot be a reparse point", path, ERROR_REPARSE_TAG_INVALID);
|
||||
verifyPrivate(descriptor.get(), path, false);
|
||||
return descriptor;
|
||||
}
|
||||
|
||||
static napi_value verifyCutterBridgeDirectory(napi_env env, const std::vector<napi_value>& args) {
|
||||
require(args.size() == 1, "Cutter bridge directory verification requires one path", L"",
|
||||
ERROR_INVALID_PARAMETER);
|
||||
const auto path = wide(env, args[0]);
|
||||
const auto directory = openPrivateBridgeDirectory(path);
|
||||
auto result = object(env);
|
||||
set(env, result, "privateDacl", boolean(env, true));
|
||||
set(env, result, "identity", identity(env, directory->get(), path));
|
||||
return result;
|
||||
}
|
||||
|
||||
static napi_value readCutterBridgeDescriptor(napi_env env, const std::vector<napi_value>& args) {
|
||||
require(args.size() == 3, "Cutter bridge descriptor read requires directory, name, and byte limit", L"",
|
||||
ERROR_INVALID_PARAMETER);
|
||||
const auto path = wide(env, args[0]);
|
||||
const auto name = wide(env, args[1]);
|
||||
const double maximum = numeric(env, args[2]);
|
||||
require(maximum >= 1 && maximum <= 64 * 1024 && maximum == static_cast<size_t>(maximum),
|
||||
"Cutter bridge descriptor limit is outside the supported range", path, ERROR_INVALID_PARAMETER);
|
||||
auto directory = openPrivateBridgeDirectory(path);
|
||||
auto descriptor = openPrivateBridgeDescriptor(directory->get(), name, path + L"\\" + name);
|
||||
FILE_STANDARD_INFO standard{};
|
||||
require(GetFileInformationByHandleEx(descriptor.get(), FileStandardInfo, &standard, sizeof(standard)),
|
||||
"Read Cutter bridge descriptor size failed", path);
|
||||
require(standard.EndOfFile.QuadPart <= maximum,
|
||||
"Cutter bridge descriptor exceeds the requested byte limit", path, ERROR_FILE_TOO_LARGE);
|
||||
std::vector<unsigned char> bytes(static_cast<size_t>(standard.EndOfFile.QuadPart));
|
||||
size_t offset = 0;
|
||||
while (offset < bytes.size()) {
|
||||
DWORD count = 0;
|
||||
const DWORD requested = static_cast<DWORD>(std::min<size_t>(bytes.size() - offset, MAXDWORD));
|
||||
require(ReadFile(descriptor.get(), bytes.data() + offset, requested, &count, nullptr) && count > 0,
|
||||
"Read Cutter bridge descriptor through its verified handle failed", path);
|
||||
offset += count;
|
||||
}
|
||||
napi_value result;
|
||||
const void* source = bytes.empty() ? static_cast<const void*>("") : bytes.data();
|
||||
check(napi_create_buffer_copy(env, bytes.size(), source, nullptr, &result));
|
||||
return result;
|
||||
}
|
||||
|
||||
std::unique_ptr<Runtime> createRuntime(const std::wstring& parentPath, const std::wstring& prefix) {
|
||||
std::unique_ptr<File> parent;
|
||||
try { parent = openFile(parentPath, 0, true); }
|
||||
|
|
@ -559,6 +655,8 @@ static napi_value snapshot(napi_env env, napi_value rootValue, Runtime& root,
|
|||
}
|
||||
|
||||
napi_value filesystemCall(napi_env env, const std::wstring& operation, const std::vector<napi_value>& args) {
|
||||
if (operation == L"cutter_bridge_verify_directory") return verifyCutterBridgeDirectory(env, args);
|
||||
if (operation == L"cutter_bridge_read_descriptor") return readCutterBridgeDescriptor(env, args);
|
||||
const auto expected = operation == L"open" || operation == L"close" || operation == L"runtime_close" || operation == L"runtime_snapshot_cancel" ? 1
|
||||
: operation == L"runtime_create" || operation == L"runtime_mkdir" || operation == L"runtime_open" ? 2 : 3;
|
||||
require(args.size() == static_cast<size_t>(expected), "Wrong native filesystem argument count", L"", ERROR_INVALID_PARAMETER);
|
||||
|
|
|
|||
|
|
@ -464,6 +464,7 @@ napi_value inspect(napi_env env, std::unique_ptr<Runtime>& probe) {
|
|||
set(env, result, "architecture", string(env, std::string("x64")));
|
||||
set(env, result, "filesystem", filesystem);
|
||||
set(env, result, "privateDacl", boolean(env, true));
|
||||
set(env, result, "privateBridgeDescriptorAccess", boolean(env, true));
|
||||
set(env, result, "atomicJobAssignment", boolean(env, true));
|
||||
set(env, result, "killOnOwnerClose", boolean(env, true));
|
||||
set(env, result, "protocolStdin", boolean(env, true));
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
10
package.json
10
package.json
|
|
@ -49,6 +49,7 @@
|
|||
"dist",
|
||||
"install.sh",
|
||||
"bridge/hopper_bridge.py",
|
||||
"bridge/cutter/rea_bridge.py",
|
||||
"bridge/native/NativeUIChildren.swift",
|
||||
"bridge/native/ReaNativeUI.swift",
|
||||
"bridge/native/rea_lldb_tracer.py",
|
||||
|
|
@ -149,6 +150,10 @@
|
|||
"test:integration": "vitest run --project boundary --project cli-boundary --project process-boundary --project mcp-boundary --project process-global --project acceptance",
|
||||
"pretest:coverage": "npm run test:prepare",
|
||||
"test:coverage": "vitest run --coverage.enabled --coverage.provider=v8",
|
||||
"test:coverage:reverse-tools": "vitest run --config vitest.reverse-tools.config.ts --coverage.enabled",
|
||||
"pretest:coverage:reverse-tools": "npm run test:prepare",
|
||||
"test:mutation:reverse-tools": "node scripts/prepare-reverse-tools-mutation.mjs && stryker run stryker.config.mjs && stryker run stryker.semantics.config.mjs",
|
||||
"pretest:mutation:reverse-tools": "npm run test:prepare",
|
||||
"test:ci": "npm run test:coverage -- --reporter=default --reporter=junit --outputFile.junit=test-results.xml",
|
||||
"pretest:ci:shard": "npm run test:prepare",
|
||||
"test:ci:shard": "npm run test:ci:shard:run",
|
||||
|
|
@ -257,6 +262,7 @@
|
|||
"build:windows-native": "node scripts/build-windows-native.mjs",
|
||||
"verify:windows-native:artifact": "node scripts/verify-windows-native-artifact.mjs",
|
||||
"prepublishOnly": "npm run verify:windows-native:artifact",
|
||||
"verify:cutter": "npm run build:cached && node scripts/verify-real-cutter.mjs",
|
||||
"verify:windows-native": "node scripts/verify-windows-native.mjs",
|
||||
"verify:ghidra:windows:package": "node scripts/verify-packaged-ghidra-windows.mjs",
|
||||
"build:ghidra:nativeaot": "node scripts/build-ghidra-nativeaot.mjs",
|
||||
|
|
@ -348,6 +354,9 @@
|
|||
},
|
||||
"devDependencies": {
|
||||
"@fast-check/vitest": "0.5.0",
|
||||
"@stryker-mutator/core": "10.0.0",
|
||||
"@stryker-mutator/typescript-checker": "10.0.0",
|
||||
"@stryker-mutator/vitest-runner": "10.0.0",
|
||||
"@types/cross-spawn": "6.0.6",
|
||||
"@types/node": "26.6.4",
|
||||
"@types/semver": "7.7.1",
|
||||
|
|
@ -364,6 +373,7 @@
|
|||
"turbo": "2.11.7",
|
||||
"typescript": "7.0.2",
|
||||
"typescript-termux": "npm:typescript@5.9.3",
|
||||
"typescript6": "npm:typescript@6.0.3",
|
||||
"vitepress": "1.6.4",
|
||||
"vitest": "5.0.3"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -62,6 +62,10 @@ function classifyPackage(base, head) {
|
|||
]),
|
||||
].filter((key) => beforeScripts?.[key] !== afterScripts?.[key]);
|
||||
for (const script of changed) {
|
||||
if (/^test:(coverage|mutation):reverse-tools$/.test(script)) {
|
||||
selected.reverse_tools = true;
|
||||
continue;
|
||||
}
|
||||
if (script === "test:sqlite" || script === "test:sqlite:run") {
|
||||
selected.sqlite = true;
|
||||
continue;
|
||||
|
|
@ -162,6 +166,7 @@ if (tier === "reused") {
|
|||
.split("\0")
|
||||
.filter(Boolean);
|
||||
for (const file of files) {
|
||||
if (matchesAny(file, SCOPES.reverse_tools)) selected.reverse_tools = true;
|
||||
if (
|
||||
/^(README(?:_[^/]+)?\.md|AGENTS\.md|CONTRIBUTING\.md|CHANGELOG\.md|SECURITY\.md|native\/windows\/README\.md|\.github\/pull_request_template\.md)$/.test(
|
||||
file,
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@ if (outputs.tier === "full" && selectedScopes.length !== CI_FLAGS.length)
|
|||
const required = reused ? ["changes"] : ["changes", "static", "test"];
|
||||
const lanes = {
|
||||
code: ["build", "test-shard"],
|
||||
reverse_tools: ["reverse-tools-quality"],
|
||||
runtime: ["windows-native-build", "package-e2e"],
|
||||
package: ["windows-curated"],
|
||||
sqlite: ["sqlite-runtime"],
|
||||
|
|
|
|||
|
|
@ -2,6 +2,62 @@
|
|||
// Shared build inputs are classified once in plan.mjs rather than repeated in
|
||||
// every workflow.
|
||||
export const SCOPES = {
|
||||
reverse_tools: [
|
||||
"src/application/reverse/**",
|
||||
"src/application/binary/BinarySession.ts",
|
||||
"src/cutter/**",
|
||||
"src/gdb/**",
|
||||
"src/frida/frida-bindings.d.ts",
|
||||
"src/objdump/**",
|
||||
"src/cli/reverseEngineeringCommands.ts",
|
||||
"src/process/ownedDebuggerSession.ts",
|
||||
"src/rizin/**",
|
||||
"src/contracts/*ToolContracts.ts",
|
||||
"src/server/registerCutterTools.ts",
|
||||
"src/server/externalMutationAdmission.ts",
|
||||
"src/server/registerGdbTools.ts",
|
||||
"src/server/registerReverseEngineeringTools.ts",
|
||||
"src/server/providerEnvironment.ts",
|
||||
"src/process/ExecFileOutput.ts",
|
||||
"src/process/ExecFileOutput.test.ts",
|
||||
"tests/composition/analysis-sessions/binarySession.test.ts",
|
||||
"tests/boundary/filesystem/directAnalysisSnapshotReplay.test.ts",
|
||||
"tests/boundary/filesystem/reverseToolsMutationConfig.test.ts",
|
||||
"src/server/registerRizinDebugTools.ts",
|
||||
"tests/boundary/providers/CutterBridge*.test.ts",
|
||||
"tests/boundary/providers/cutterBridgeProducer.test.ts",
|
||||
"tests/boundary/providers/GdbSession*.test.ts",
|
||||
"tests/boundary/providers/GdbCommandQueue.test.ts",
|
||||
"tests/boundary/providers/gdbTestHelpers.ts",
|
||||
"tests/boundary/providers/RizinDebugSessionCoverage.part*.test.ts",
|
||||
"tests/boundary/providers/RizinCommandTimeout.test.ts",
|
||||
"tests/boundary/providers/RizinTimeoutEvidence.test.ts",
|
||||
"tests/boundary/providers/RizinDebugSession*.test.ts",
|
||||
"tests/boundary/providers/RizinInferiorTty.test.ts",
|
||||
"tests/boundary/providers/RizinTestInferiorTty.ts",
|
||||
"tests/boundary/process/ownedDebuggerSession.test.ts",
|
||||
"tests/boundary/cli/gdbStartupCleanupCli.test.ts",
|
||||
"tests/boundary/cli/reverseEngineeringCommands.test.ts",
|
||||
"tests/boundary/mcp/cutter*.test.ts",
|
||||
"tests/boundary/mcp/gdbCancellationMcp.test.ts",
|
||||
"tests/boundary/mcp/debuggerCancellationMcp.test.ts",
|
||||
"tests/boundary/mcp/rizinDebuggerCancellationMcp.test.ts",
|
||||
"tests/boundary/mcp/reverseEngineeringPartialMcp.test.ts",
|
||||
"tests/boundary/mcp/workflowSnapshotReplayMcp.test.ts",
|
||||
"tests/boundary/mcp/reverseEngineeringEnvironmentMcp.test.ts",
|
||||
"tests/support/reverseEngineering*.ts",
|
||||
"tests/support/ownedRizinCommandEvidenceTests.ts",
|
||||
"tests/support/reverseDebuggerMcpTestHelpers.ts",
|
||||
"tests/processOwnershipRunner.ts",
|
||||
"tests/composition/reverseEngineering*.test.ts",
|
||||
"vitest.reverse-tools.config.ts",
|
||||
"vitest.reverse-tools-semantics.config.ts",
|
||||
"stryker.config.mjs",
|
||||
"stryker.semantics.config.mjs",
|
||||
"tsconfig.reverse-tools-mutation.json",
|
||||
"scripts/prepare-reverse-tools-mutation.mjs",
|
||||
"scripts/lib/reverse-tools-mutation-targets.mjs",
|
||||
],
|
||||
// Apple artifact CLI/MCP lane on macOS, including its focused regressions.
|
||||
apple: [
|
||||
"src/native/Apple*",
|
||||
|
|
|
|||
|
|
@ -19,6 +19,11 @@ export const toolFamilyCatalog = (sources) => {
|
|||
contracts: [
|
||||
...sources.nativeContracts.NATIVE_TOOL_CONTRACTS,
|
||||
...sources.binaryDiagnosticsContracts.BINARY_DIAGNOSTICS_TOOL_CONTRACTS,
|
||||
...sources.cutterContracts.CUTTER_TOOL_CONTRACTS,
|
||||
...sources.reverseEngineeringContracts
|
||||
.REVERSE_ENGINEERING_TOOL_CONTRACTS,
|
||||
...sources.gdbContracts.GDB_TOOL_CONTRACTS,
|
||||
...sources.rizinDebugContracts.RIZIN_DEBUG_TOOL_CONTRACTS,
|
||||
],
|
||||
},
|
||||
{
|
||||
|
|
@ -164,6 +169,32 @@ export const providerCatalog = (sources) => {
|
|||
activeContracts,
|
||||
} = electronContractSlices(sources);
|
||||
return [
|
||||
{
|
||||
identity: sources.cutterProvider.CUTTER_PROVIDER_IDENTITY,
|
||||
contracts: sources.cutterContracts.CUTTER_TOOL_CONTRACTS,
|
||||
},
|
||||
{
|
||||
identity: sources.objdumpProvider.OBJDUMP_PROVIDER_IDENTITY,
|
||||
contracts:
|
||||
sources.reverseEngineeringContracts.REVERSE_ENGINEERING_TOOL_CONTRACTS.filter(
|
||||
({ name }) => name === "inspect_with_objdump",
|
||||
),
|
||||
},
|
||||
{
|
||||
identity: sources.rizinProvider.RIZIN_PROVIDER_IDENTITY,
|
||||
contracts:
|
||||
sources.reverseEngineeringContracts.REVERSE_ENGINEERING_TOOL_CONTRACTS.filter(
|
||||
({ name }) => name === "execute_rizin_command",
|
||||
),
|
||||
},
|
||||
{
|
||||
identity: sources.gdbProvider.GDB_PROVIDER_IDENTITY,
|
||||
contracts: sources.gdbContracts.GDB_TOOL_CONTRACTS,
|
||||
},
|
||||
{
|
||||
identity: sources.rizinDebugProvider.RIZIN_DEBUGGER_PROVIDER_IDENTITY,
|
||||
contracts: sources.rizinDebugContracts.RIZIN_DEBUG_TOOL_CONTRACTS,
|
||||
},
|
||||
{
|
||||
identity: sources.fridaProvider.FRIDA_PROVIDER_IDENTITY,
|
||||
contracts: sources.fridaContracts.FRIDA_TOOL_CONTRACTS,
|
||||
|
|
|
|||
|
|
@ -43,6 +43,16 @@ const SOURCE_PATHS = {
|
|||
sessionContracts: "dist/contracts/sessionToolContracts.js",
|
||||
binaryDiagnosticsContracts:
|
||||
"dist/contracts/native/binaryDiagnosticsToolContracts.js",
|
||||
cutterContracts: "dist/contracts/cutterToolContracts.js",
|
||||
cutterProvider: "dist/cutter/CutterBridgeClient.js",
|
||||
reverseEngineeringContracts:
|
||||
"dist/contracts/reverseEngineeringToolContracts.js",
|
||||
gdbContracts: "dist/contracts/gdbToolContracts.js",
|
||||
gdbProvider: "dist/gdb/GdbSessionManager.js",
|
||||
rizinDebugContracts: "dist/contracts/rizinDebugToolContracts.js",
|
||||
objdumpProvider: "dist/objdump/ObjdumpCommand.js",
|
||||
rizinProvider: "dist/rizin/RizinCommand.js",
|
||||
rizinDebugProvider: "dist/rizin/RizinDebugSessionManager.js",
|
||||
fridaContracts: "dist/contracts/fridaToolContracts.js",
|
||||
fridaProvider: "dist/frida/FridaInstrumentationManager.js",
|
||||
pwntoolsProvider: "dist/native/pwntools/PwntoolsRelease.js",
|
||||
|
|
|
|||
|
|
@ -0,0 +1,182 @@
|
|||
import { readFileSync } from "node:fs";
|
||||
import ts from "typescript6";
|
||||
|
||||
const compact = (text) => text.replace(/\s+/gu, "");
|
||||
const nameOf = (node, source) => node.name?.getText(source);
|
||||
|
||||
const methodOwner = (node, source) => {
|
||||
for (let parent = node; parent !== undefined; parent = parent.parent) {
|
||||
if (ts.isMethodDeclaration(parent) || ts.isConstructorDeclaration(parent))
|
||||
return {
|
||||
owner: nameOf(parent.parent, source),
|
||||
body: parent.body,
|
||||
method: ts.isConstructorDeclaration(parent)
|
||||
? "constructor"
|
||||
: nameOf(parent, source),
|
||||
};
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const withinOwner = (node, selector, source) => {
|
||||
if (selector.owner === undefined) return true;
|
||||
const owner = methodOwner(node, source);
|
||||
return (
|
||||
owner?.owner === selector.owner &&
|
||||
(selector.method === undefined || owner.method === selector.method)
|
||||
);
|
||||
};
|
||||
|
||||
const contains = (node, predicate) =>
|
||||
predicate(node) ||
|
||||
ts.forEachChild(node, (child) => contains(child, predicate)) === true;
|
||||
|
||||
const inBlockWithBinding = (node, binding) => {
|
||||
if (binding === undefined) return true;
|
||||
let block = node.parent;
|
||||
while (block !== undefined && !ts.isBlock(block)) block = block.parent;
|
||||
return block?.statements.some(
|
||||
(statement) =>
|
||||
ts.isVariableStatement(statement) &&
|
||||
statement.declarationList.declarations.some(
|
||||
(declaration) =>
|
||||
ts.isIdentifier(declaration.name) &&
|
||||
declaration.name.text === binding,
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
const isCommandHandler = (node, command) => {
|
||||
if (!ts.isArrowFunction(node)) return false;
|
||||
const property = node.parent;
|
||||
if (
|
||||
!ts.isPropertyAssignment(property) ||
|
||||
!ts.isIdentifier(property.name) ||
|
||||
property.name.text !== "run"
|
||||
)
|
||||
return false;
|
||||
const call = property.parent.parent;
|
||||
if (
|
||||
!ts.isCallExpression(call) ||
|
||||
!ts.isPropertyAccessExpression(call.expression) ||
|
||||
!ts.isIdentifier(call.expression.expression) ||
|
||||
call.expression.expression.text !== "cli" ||
|
||||
call.expression.name.text !== "command"
|
||||
)
|
||||
return false;
|
||||
const selected = call.arguments[0];
|
||||
return (
|
||||
selected !== undefined &&
|
||||
ts.isPropertyAccessExpression(selected) &&
|
||||
ts.isIdentifier(selected.expression) &&
|
||||
selected.expression.text === "CLI_COMMANDS" &&
|
||||
selected.name.text === command
|
||||
);
|
||||
};
|
||||
|
||||
const matches = (node, selector, source) => {
|
||||
if (!withinOwner(node, selector, source)) return false;
|
||||
if (
|
||||
selector.direct === true &&
|
||||
node.parent !== methodOwner(node, source)?.body
|
||||
)
|
||||
return false;
|
||||
switch (selector.kind) {
|
||||
case "expression":
|
||||
return (
|
||||
ts.isBinaryExpression(node) &&
|
||||
compact(node.getText(source)) === compact(selector.text)
|
||||
);
|
||||
case "declaration":
|
||||
return (
|
||||
ts.isVariableDeclaration(node) &&
|
||||
nameOf(node, source) === selector.name &&
|
||||
inBlockWithBinding(node, selector.withinBlockWithBinding)
|
||||
);
|
||||
case "method":
|
||||
return (
|
||||
ts.isMethodDeclaration(node) && nameOf(node, source) === selector.name
|
||||
);
|
||||
case "constructor":
|
||||
return ts.isConstructorDeclaration(node);
|
||||
case "guard":
|
||||
return (
|
||||
ts.isIfStatement(node) &&
|
||||
compact(node.expression.getText(source)) === compact(selector.condition)
|
||||
);
|
||||
case "call":
|
||||
return (
|
||||
ts.isCallExpression(node) &&
|
||||
compact(node.expression.getText(source)) === compact(selector.callee) &&
|
||||
(selector.event === undefined ||
|
||||
(node.arguments[0] !== undefined &&
|
||||
ts.isStringLiteral(node.arguments[0]) &&
|
||||
node.arguments[0].text === selector.event))
|
||||
);
|
||||
case "try":
|
||||
return (
|
||||
ts.isTryStatement(node) &&
|
||||
contains(node.tryBlock, (child) =>
|
||||
selector.assignment !== undefined
|
||||
? ts.isBinaryExpression(child) &&
|
||||
child.operatorToken.kind === ts.SyntaxKind.EqualsToken &&
|
||||
ts.isIdentifier(child.left) &&
|
||||
child.left.text === selector.assignment
|
||||
: ts.isCallExpression(child) &&
|
||||
compact(child.expression.getText(source)) ===
|
||||
compact(selector.callee),
|
||||
)
|
||||
);
|
||||
case "command-handler":
|
||||
return isCommandHandler(node, selector.command);
|
||||
default:
|
||||
throw new Error(
|
||||
`Unknown mutation selector kind: ${String(selector.kind)}`,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
/** Parse a source once and resolve one owned region for every requested selector. */
|
||||
export const mutationRanges = (path, selectors) => {
|
||||
if (!Array.isArray(selectors) || selectors.length === 0)
|
||||
throw new Error(`Mutation selectors must be nonempty: ${path}`);
|
||||
for (const selector of selectors)
|
||||
if (
|
||||
["method", "constructor", "guard", "call", "try"].includes(
|
||||
selector.kind,
|
||||
) &&
|
||||
typeof selector.owner !== "string"
|
||||
)
|
||||
throw new Error(
|
||||
`Mutation selector requires a named class owner: ${path}`,
|
||||
);
|
||||
const source = ts.createSourceFile(
|
||||
path,
|
||||
readFileSync(path, "utf8"),
|
||||
ts.ScriptTarget.Latest,
|
||||
true,
|
||||
);
|
||||
if (source.parseDiagnostics.length !== 0)
|
||||
throw new Error(
|
||||
`Cannot select mutations from malformed TypeScript: ${path}`,
|
||||
);
|
||||
const found = selectors.map(() => []);
|
||||
const visit = (node) => {
|
||||
for (const [index, selector] of selectors.entries())
|
||||
if (matches(node, selector, source)) found[index].push(node);
|
||||
ts.forEachChild(node, visit);
|
||||
};
|
||||
visit(source);
|
||||
return selectors.map((selector, index) => {
|
||||
const candidates = found[index];
|
||||
if (candidates.length !== 1)
|
||||
throw new Error(
|
||||
`Expected one mutation target in ${path}: ${JSON.stringify(selector)}; found ${candidates.length}`,
|
||||
);
|
||||
const node = candidates[0];
|
||||
const first =
|
||||
source.getLineAndCharacterOfPosition(node.getStart(source)).line + 1;
|
||||
const last = source.getLineAndCharacterOfPosition(node.getEnd()).line + 1;
|
||||
return `${path}:${first}-${last}`;
|
||||
});
|
||||
};
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
import { mkdir, rm, symlink } from "node:fs/promises";
|
||||
import { dirname, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const root = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const compiler = resolve(root, "node_modules/typescript6");
|
||||
|
||||
for (const packageName of ["core", "typescript-checker"]) {
|
||||
const packagePath = resolve(
|
||||
root,
|
||||
"node_modules/@stryker-mutator",
|
||||
packageName,
|
||||
);
|
||||
const nodeModulesPath = resolve(packagePath, "node_modules");
|
||||
const typescriptPath = resolve(nodeModulesPath, "typescript");
|
||||
await mkdir(nodeModulesPath, { recursive: true });
|
||||
await rm(typescriptPath, { recursive: true, force: true });
|
||||
await symlink(compiler, typescriptPath, "dir");
|
||||
}
|
||||
|
|
@ -4,6 +4,7 @@ import { promisify } from "node:util";
|
|||
|
||||
const exec = promisify(execFile);
|
||||
const packedHopperBridge = "package/bridge/hopper_bridge.py";
|
||||
const packedCutterBridge = "package/bridge/cutter/rea_bridge.py";
|
||||
const packedGhidraBridge = "package/bridge/ghidra/ReaGhidraBridge.java";
|
||||
const packedGhidraActivator =
|
||||
"package/bridge/ghidra/ReaGhidraBundleActivator.java";
|
||||
|
|
@ -12,6 +13,7 @@ const packedGhidraActivator =
|
|||
export async function verifyPackedBridge({ workspace, tarball, packedFiles }) {
|
||||
for (const path of [
|
||||
packedHopperBridge,
|
||||
packedCutterBridge,
|
||||
packedGhidraBridge,
|
||||
packedGhidraActivator,
|
||||
]) {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,214 @@
|
|||
#!/usr/bin/env node
|
||||
|
||||
import assert from "node:assert/strict";
|
||||
import { Client, InMemoryTransport } from "@modelcontextprotocol/client";
|
||||
import { execFile } from "node:child_process";
|
||||
import { createHash, randomUUID } from "node:crypto";
|
||||
import {
|
||||
access,
|
||||
constants,
|
||||
mkdtemp,
|
||||
readFile,
|
||||
rm,
|
||||
writeFile,
|
||||
} from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join, resolve } from "node:path";
|
||||
import { promisify } from "node:util";
|
||||
import { createServer } from "../dist/server/createServer.js";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
const cli = resolve("scripts/rea.mjs");
|
||||
const runCli = async (...arguments_) => {
|
||||
const { stdout } = await execFileAsync(
|
||||
process.execPath,
|
||||
[cli, ...arguments_, "--format", "json"],
|
||||
{
|
||||
encoding: "utf8",
|
||||
maxBuffer: 4 * 1024 * 1024,
|
||||
env: process.env,
|
||||
},
|
||||
);
|
||||
return JSON.parse(stdout);
|
||||
};
|
||||
|
||||
const projectRoot = await mkdtemp(join(tmpdir(), "rea-cutter-smoke-"));
|
||||
const projectPath = join(projectRoot, "smoke.rzdb");
|
||||
const rizinQuote = (value) =>
|
||||
`"${value.replaceAll("\\", "\\\\").replaceAll('"', '\\"')}"`;
|
||||
const unavailableAnalysis = {
|
||||
execute: async () => {
|
||||
throw new Error(
|
||||
"The Cutter verification does not call unrelated analysis tools.",
|
||||
);
|
||||
},
|
||||
};
|
||||
const expectedPlatform = {
|
||||
Linux: "linux",
|
||||
macOS: "darwin",
|
||||
Windows: "win32",
|
||||
}[process.env.EXPECTED_PLATFORM];
|
||||
if (expectedPlatform !== undefined)
|
||||
assert.equal(
|
||||
process.platform,
|
||||
expectedPlatform,
|
||||
"Cutter runner did not match its declared platform.",
|
||||
);
|
||||
try {
|
||||
const discovery = await runCli("list-cutter-sessions");
|
||||
assert.equal(
|
||||
discovery.discovery_status,
|
||||
"sessions_found",
|
||||
"Open upstream Cutter with the REA plugin before running this smoke test.",
|
||||
);
|
||||
assert.ok(discovery.sessions.length > 0);
|
||||
const session = discovery.sessions[0];
|
||||
assert.ok(session);
|
||||
assert.ok(
|
||||
session.cutter_version,
|
||||
"The upstream Cutter API did not report its version.",
|
||||
);
|
||||
if (process.env.REA_CUTTER_EXPECTED_VERSION !== undefined)
|
||||
assert.ok(
|
||||
session.cutter_version.includes(process.env.REA_CUTTER_EXPECTED_VERSION),
|
||||
"The live Cutter version did not match the configured version.",
|
||||
);
|
||||
assert.ok(
|
||||
session.current_file,
|
||||
"Open a disposable local binary in Cutter before running this smoke test.",
|
||||
);
|
||||
const artifactPath = session.current_file;
|
||||
const artifactDigest = createHash("sha256")
|
||||
.update(await readFile(artifactPath))
|
||||
.digest("hex");
|
||||
|
||||
const inspection = await runCli(
|
||||
"cutter-command",
|
||||
session.session_id,
|
||||
String(session.document_generation),
|
||||
"ij",
|
||||
"--json",
|
||||
);
|
||||
assert.equal(inspection.predicate_type, "rea.cutter.command-observation");
|
||||
assert.ok(
|
||||
Object.hasOwn(inspection, "raw_result"),
|
||||
"Cutter ij result was not retained as Evidence.",
|
||||
);
|
||||
assert.equal(typeof inspection.raw_result.bin?.arch, "string");
|
||||
|
||||
const mcpServer = createServer(
|
||||
{ kind: "fixed", analysis: unavailableAnalysis },
|
||||
{
|
||||
providerEnvironment: process.env,
|
||||
},
|
||||
);
|
||||
const mcpClient = new Client({
|
||||
name: "rea-cutter-upstream-smoke",
|
||||
version: "1",
|
||||
});
|
||||
const [clientTransport, serverTransport] =
|
||||
InMemoryTransport.createLinkedPair();
|
||||
try {
|
||||
await mcpServer.connect(serverTransport);
|
||||
await mcpClient.connect(clientTransport);
|
||||
const mcpDiscovery = await mcpClient.callTool({
|
||||
name: "list_cutter_sessions",
|
||||
arguments: {},
|
||||
});
|
||||
assert.notEqual(mcpDiscovery.isError, true);
|
||||
assert.equal(
|
||||
mcpDiscovery.structuredContent.discovery_status,
|
||||
"sessions_found",
|
||||
);
|
||||
const mcpInspection = await mcpClient.callTool({
|
||||
name: "cutter_command",
|
||||
arguments: {
|
||||
session_id: session.session_id,
|
||||
expected_generation: session.document_generation,
|
||||
command: "ij",
|
||||
json: true,
|
||||
},
|
||||
});
|
||||
assert.notEqual(mcpInspection.isError, true);
|
||||
assert.deepEqual(
|
||||
mcpInspection.structuredContent.normalized_result,
|
||||
inspection.normalized_result,
|
||||
"Cutter CLI and MCP returned different analysis results.",
|
||||
);
|
||||
} finally {
|
||||
await mcpClient.close();
|
||||
await mcpServer.close();
|
||||
}
|
||||
|
||||
const annotation = `rea-smoke-${randomUUID()}`;
|
||||
await runCli(
|
||||
"cutter-command",
|
||||
session.session_id,
|
||||
String(session.document_generation),
|
||||
`CC ${annotation} @ entry0`,
|
||||
);
|
||||
await runCli(
|
||||
"cutter-command",
|
||||
session.session_id,
|
||||
String(session.document_generation),
|
||||
`Ps ${rizinQuote(projectPath)}`,
|
||||
);
|
||||
await access(projectPath, constants.R_OK | constants.W_OK);
|
||||
await runCli(
|
||||
"cutter-command",
|
||||
session.session_id,
|
||||
String(session.document_generation),
|
||||
"CC- @ entry0",
|
||||
);
|
||||
const removed = await runCli(
|
||||
"cutter-command",
|
||||
session.session_id,
|
||||
String(session.document_generation),
|
||||
"CC. @ entry0",
|
||||
);
|
||||
assert.ok(
|
||||
!String(removed.raw_result).includes(annotation),
|
||||
"The in-memory annotation was not removed before project reopening.",
|
||||
);
|
||||
await runCli(
|
||||
"cutter-command",
|
||||
session.session_id,
|
||||
String(session.document_generation),
|
||||
`Po ${rizinQuote(projectPath)}`,
|
||||
);
|
||||
const recovered = await runCli(
|
||||
"cutter-command",
|
||||
session.session_id,
|
||||
String(session.document_generation),
|
||||
"CC. @ entry0",
|
||||
);
|
||||
assert.equal(recovered.predicate_type, "rea.cutter.command-observation");
|
||||
assert.ok(
|
||||
String(recovered.raw_result).includes(annotation),
|
||||
"The saved project did not preserve the analysis comment after reopening.",
|
||||
);
|
||||
const finalArtifactDigest = createHash("sha256")
|
||||
.update(await readFile(artifactPath))
|
||||
.digest("hex");
|
||||
assert.equal(
|
||||
finalArtifactDigest,
|
||||
artifactDigest,
|
||||
"Cutter project edits changed the executable bytes.",
|
||||
);
|
||||
|
||||
const report = {
|
||||
ok: true,
|
||||
platform: process.platform,
|
||||
cutter_version: session.cutter_version,
|
||||
document_generation: session.document_generation,
|
||||
cli_mcp_evidence_parity: true,
|
||||
annotation_saved_and_reopened: true,
|
||||
executable_digest_unchanged: true,
|
||||
};
|
||||
await writeFile("cutter-smoke.json", `${JSON.stringify(report, null, 2)}\n`, {
|
||||
mode: 0o600,
|
||||
});
|
||||
console.log(JSON.stringify(report));
|
||||
} finally {
|
||||
await rm(projectRoot, { recursive: true, force: true });
|
||||
}
|
||||
|
|
@ -148,6 +148,10 @@ process.stdout.write(JSON.stringify({executable:basename(process.execPath),abiVe
|
|||
"//localhost/share/target.bin",
|
||||
])
|
||||
assert.throws(() => native.call("open", [rejected]));
|
||||
assert.throws(
|
||||
() => native.call("cutter_bridge_verify_directory", [sourceDirectory]),
|
||||
/owner|DACL/u,
|
||||
);
|
||||
report.controls.ordinaryDriveSeparatorsAndRequestedIdentity = true;
|
||||
// An owned runtime's DELETE lease conflicts with a fresh parent admission.
|
||||
// This produces a genuine sharing failure after successful authority caching,
|
||||
|
|
@ -282,6 +286,10 @@ process.stdout.write(JSON.stringify({executable:basename(process.execPath),abiVe
|
|||
() => native.call("open", [join(junction, "target.bin")]),
|
||||
/Reparse/u,
|
||||
);
|
||||
assert.throws(
|
||||
() => native.call("cutter_bridge_verify_directory", [junction]),
|
||||
/Reparse/u,
|
||||
);
|
||||
assert.throws(() => native.call("open", [source + ":stream"]));
|
||||
const runtime = native.call("runtime_create", [
|
||||
workspace.replaceAll("\\", "/"),
|
||||
|
|
@ -326,6 +334,47 @@ process.stdout.write(JSON.stringify({executable:basename(process.execPath),abiVe
|
|||
"descriptor.json",
|
||||
Buffer.from("fixture"),
|
||||
]);
|
||||
assert.equal(
|
||||
native.call("cutter_bridge_verify_directory", [runtime.path]).privateDacl,
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
native
|
||||
.call("cutter_bridge_read_descriptor", [
|
||||
runtime.path,
|
||||
"descriptor.json",
|
||||
64 * 1024,
|
||||
])
|
||||
.toString("utf8"),
|
||||
"fixture",
|
||||
);
|
||||
const descriptorJunction = join(runtime.path, "descriptor-link.json");
|
||||
await symlink(sourceDirectory, descriptorJunction, "junction");
|
||||
assert.throws(() =>
|
||||
native.call("cutter_bridge_read_descriptor", [
|
||||
runtime.path,
|
||||
"descriptor-link.json",
|
||||
64 * 1024,
|
||||
]),
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
native.call("cutter_bridge_read_descriptor", [
|
||||
runtime.path,
|
||||
"descriptor.json",
|
||||
3,
|
||||
]),
|
||||
/byte limit/u,
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
native.call("cutter_bridge_read_descriptor", [
|
||||
runtime.path,
|
||||
"..\\escape.json",
|
||||
64 * 1024,
|
||||
]),
|
||||
/filename component/u,
|
||||
);
|
||||
assert.throws(() =>
|
||||
native.call("runtime_write", [
|
||||
runtime.handle,
|
||||
|
|
|
|||
|
|
@ -158,7 +158,7 @@ const expandAddress = async (
|
|||
state.residual.add(`Call relationships were unavailable for ${address}.`);
|
||||
return ok(false);
|
||||
}
|
||||
const related = parseRelatedAddresses(result.value.result, relation);
|
||||
const related = parseRelatedAddresses(result.value.result, relation, tool);
|
||||
if (!related.ok) {
|
||||
state.failures.push({
|
||||
address,
|
||||
|
|
|
|||
|
|
@ -5,7 +5,10 @@ import {
|
|||
analysisProfilesEqual,
|
||||
type AnalysisProfileCommitment,
|
||||
} from "../../domain/analysisProfile.js";
|
||||
import { AnalysisCancelledError } from "../../domain/analysisErrorCore.js";
|
||||
import {
|
||||
AnalysisCancelledError,
|
||||
AnalysisOutputError,
|
||||
} from "../../domain/analysisErrorCore.js";
|
||||
import type { AnalysisError } from "../../domain/analysisErrorBase.js";
|
||||
import { err, ok, type Result } from "../../domain/result.js";
|
||||
import type { JsonValue } from "../../domain/jsonValue.js";
|
||||
|
|
@ -73,6 +76,7 @@ export class BinarySession
|
|||
implements BinarySessionPort
|
||||
{
|
||||
#active: SessionBinding | undefined;
|
||||
#externalMutationCleanupFailure: AnalysisError | undefined;
|
||||
#pendingCleanup:
|
||||
| {
|
||||
readonly client: AnalysisClient;
|
||||
|
|
@ -158,6 +162,10 @@ export class BinarySession
|
|||
);
|
||||
}
|
||||
|
||||
invalidateSnapshotForExternalMutation(): void {
|
||||
this.invalidateSnapshot();
|
||||
}
|
||||
|
||||
/** Return the selected immutable profile, optionally scoped to an operation. */
|
||||
analysisProfile(
|
||||
operation?: AnalysisOperation,
|
||||
|
|
@ -245,7 +253,18 @@ export class BinarySession
|
|||
resolve: () => Promise<Result<ResolvedSessionOpen, AnalysisError>>,
|
||||
options: Pick<BinarySessionOpenOptions, "signal" | "snapshot">,
|
||||
): Promise<Result<BinaryTarget, AnalysisError>> {
|
||||
return this.#serialize(async (admittedThrough) => {
|
||||
return this.#serialize((admittedThrough) =>
|
||||
this.#openWithinTransition(admittedThrough, resolve, options),
|
||||
);
|
||||
}
|
||||
|
||||
async #openWithinTransition(
|
||||
admittedThrough: number,
|
||||
resolve: () => Promise<Result<ResolvedSessionOpen, AnalysisError>>,
|
||||
options: Pick<BinarySessionOpenOptions, "signal" | "snapshot">,
|
||||
): Promise<Result<BinaryTarget, AnalysisError>> {
|
||||
if (this.#externalMutationCleanupFailure?.cleanupIncomplete === true)
|
||||
return err(this.#externalMutationCleanupFailure);
|
||||
if (isAborted(options.signal))
|
||||
return err(new AnalysisCancelledError("open_binary"));
|
||||
const resolved = await resolve();
|
||||
|
|
@ -349,6 +368,65 @@ export class BinarySession
|
|||
}
|
||||
}
|
||||
return ok(target);
|
||||
}
|
||||
|
||||
/** Serialize an external mutation and retire the potentially stale analysis. */
|
||||
withAdmittedExternalMutation<Value>(
|
||||
_path: string | undefined,
|
||||
signal: AbortSignal | undefined,
|
||||
operation: () => Promise<Result<Value, AnalysisError>>,
|
||||
retirementFailure: (
|
||||
value: Result<Value, AnalysisError>,
|
||||
error: AnalysisError,
|
||||
) => AnalysisError,
|
||||
operationName = "execute_rizin_command",
|
||||
cleanupRecovery?: (
|
||||
failure: AnalysisError | undefined,
|
||||
) => Promise<Result<null, AnalysisError>>,
|
||||
): Promise<Result<Result<Value, AnalysisError>, AnalysisError>> {
|
||||
return this.#serialize(async (admittedThrough) => {
|
||||
if (isAborted(signal))
|
||||
return err(new AnalysisCancelledError(operationName));
|
||||
await this.#drainCalls(admittedThrough);
|
||||
if (isAborted(signal))
|
||||
return err(new AnalysisCancelledError(operationName));
|
||||
|
||||
if (this.#externalMutationCleanupFailure?.cleanupIncomplete === true) {
|
||||
if (cleanupRecovery === undefined)
|
||||
return err(this.#externalMutationCleanupFailure);
|
||||
const recovered = await cleanupRecovery(
|
||||
this.#externalMutationCleanupFailure,
|
||||
);
|
||||
if (!recovered.ok) return recovered;
|
||||
if (this.#externalMutationCleanupFailure.cleanupIncomplete)
|
||||
return err(this.#externalMutationCleanupFailure);
|
||||
this.#externalMutationCleanupFailure = undefined;
|
||||
}
|
||||
|
||||
const pendingCleanup = await this.#retryPendingCleanup();
|
||||
if (!pendingCleanup.ok) return pendingCleanup;
|
||||
|
||||
const activeAtDispatch = this.#active;
|
||||
if (activeAtDispatch?.route.binding?.identity.id === "ghidra")
|
||||
return err(
|
||||
new AnalysisOutputError(
|
||||
operationName,
|
||||
"Close the active Ghidra session explicitly before an external mutation; rebinding would discard its ephemeral analysis edits.",
|
||||
),
|
||||
);
|
||||
// A protocol reply does not prove that background writers have stopped.
|
||||
// Retire the old analysis and require an explicit fresh open rather than
|
||||
// silently binding a snapshot while an unrestricted command can still write.
|
||||
const retireTarget = activeAtDispatch !== undefined;
|
||||
this.invalidateSnapshot();
|
||||
const value = await operation();
|
||||
this.#externalMutationCleanupFailure =
|
||||
!value.ok && value.error.cleanupIncomplete ? value.error : undefined;
|
||||
if (!retireTarget) return ok(value);
|
||||
|
||||
const retired = await this.#closeActive({}, false);
|
||||
if (!retired.ok) return err(retirementFailure(value, retired.error));
|
||||
return ok(value);
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -396,6 +474,7 @@ export class BinarySession
|
|||
options: Pick<ExecutionOptions, "progress"> & {
|
||||
readonly retainProviderDocuments?: boolean;
|
||||
},
|
||||
clearRecords = true,
|
||||
): Promise<Result<null, AnalysisError>> {
|
||||
const previous = this.#active;
|
||||
this.#active = undefined;
|
||||
|
|
@ -417,7 +496,7 @@ export class BinarySession
|
|||
: {}),
|
||||
},
|
||||
);
|
||||
this.clearSessionRecords();
|
||||
if (clearRecords) this.clearSessionRecords();
|
||||
this.#clearRuntimeAvailability();
|
||||
const fileResources = await this.closeWriteResources();
|
||||
if (!closed.ok && !fileResources.ok)
|
||||
|
|
|
|||
|
|
@ -48,6 +48,20 @@ export interface BinarySessionPort
|
|||
signal: AbortSignal | undefined,
|
||||
operation: (analysis: AnalysisOperationPort) => Promise<Value>,
|
||||
): Promise<Result<Value, AnalysisError>>;
|
||||
/** Serialize an external mutation and retire any active target afterward. */
|
||||
withAdmittedExternalMutation<Value>(
|
||||
_path: string | undefined,
|
||||
signal: AbortSignal | undefined,
|
||||
operation: () => Promise<Result<Value, AnalysisError>>,
|
||||
retirementFailure: (
|
||||
value: Result<Value, AnalysisError>,
|
||||
error: AnalysisError,
|
||||
) => AnalysisError,
|
||||
operationName?: string,
|
||||
cleanupRecovery?: (
|
||||
failure: AnalysisError | undefined,
|
||||
) => Promise<Result<null, AnalysisError>>,
|
||||
): Promise<Result<Result<Value, AnalysisError>, AnalysisError>>;
|
||||
open(
|
||||
path: string,
|
||||
options?: {
|
||||
|
|
@ -82,6 +96,8 @@ export interface BinarySessionPort
|
|||
operation?: AnalysisOperation,
|
||||
): AnalysisProfileCommitment | undefined;
|
||||
allowsSnapshotReplay(operation: AnalysisOperation): boolean;
|
||||
/** Invalidate cached Evidence before an external command may mutate the target. */
|
||||
invalidateSnapshotForExternalMutation(): void;
|
||||
recordWorkflowSnapshot(
|
||||
input: WorkflowSnapshotRecordInput,
|
||||
): Result<null, EvidenceIntegrityError>;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,19 @@
|
|||
import type { EvidenceProvider } from "../../domain/evidence.js";
|
||||
import type {
|
||||
ObjdumpInput,
|
||||
RizinInput,
|
||||
} from "../../domain/reverseEngineering.js";
|
||||
|
||||
/** Provider-neutral command profile consumed by the shared application workflow. */
|
||||
export interface ReverseEngineeringProviderCommand {
|
||||
readonly executable: string;
|
||||
readonly arguments: readonly string[];
|
||||
readonly identity: EvidenceProvider;
|
||||
readonly environmentOverrides?: Readonly<NodeJS.ProcessEnv>;
|
||||
}
|
||||
|
||||
/** Provider-specific command construction stays outside the application layer. */
|
||||
export interface ReverseEngineeringCommandPort {
|
||||
objdump(input: ObjdumpInput): ReverseEngineeringProviderCommand;
|
||||
rizin(input: RizinInput): ReverseEngineeringProviderCommand;
|
||||
}
|
||||
|
|
@ -0,0 +1,948 @@
|
|||
import { createHash, randomUUID } from "node:crypto";
|
||||
import { createReadStream } from "node:fs";
|
||||
import { stat as statPath } from "node:fs/promises";
|
||||
import type { Stats } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
import {
|
||||
AnalysisAccessDeniedError,
|
||||
AnalysisCancelledError,
|
||||
AnalysisCapabilityUnavailableError,
|
||||
AnalysisInputError,
|
||||
AnalysisResourceConstraintError,
|
||||
AnalysisTimeoutError,
|
||||
} from "../../domain/analysisErrorCore.js";
|
||||
import type {
|
||||
AnalysisCleanupObservation,
|
||||
AnalysisError,
|
||||
} from "../../domain/analysisErrorBase.js";
|
||||
import { AnalysisOutputError } from "../../domain/analysisErrorCore.js";
|
||||
import { createEvidence, type Evidence } from "../../domain/evidence.js";
|
||||
import type { JsonValue } from "../../domain/jsonValue.js";
|
||||
import { err, ok, type Result } from "../../domain/result.js";
|
||||
import {
|
||||
objdumpInputSchema,
|
||||
rizinInputSchema,
|
||||
} from "../../domain/reverseEngineering.js";
|
||||
import type {
|
||||
ReverseEngineeringCommandPort,
|
||||
ReverseEngineeringProviderCommand,
|
||||
} from "./ReverseEngineeringCommandPort.js";
|
||||
import {
|
||||
execFileOutput,
|
||||
execFileOutputFailure,
|
||||
} from "../../process/ExecFileOutput.js";
|
||||
import {
|
||||
OwnedCommandFailure,
|
||||
runOwnedCommand,
|
||||
} from "../../process/OwnedCommand.js";
|
||||
import type { ProviderProcessSupervisor } from "../../process/ProviderProcess.js";
|
||||
|
||||
export type ReverseEngineeringOperation =
|
||||
| "inspect_with_objdump"
|
||||
| "execute_rizin_command";
|
||||
|
||||
const DEFAULT_TIMEOUT_MS = 120_000;
|
||||
const MAX_COMBINED_OUTPUT_BYTES = 16 * 1024 * 1024;
|
||||
// Eight concurrent captures bound retained provider output to 128 MiB.
|
||||
const MAX_CONCURRENT_REVERSE_ENGINEERING_OPERATIONS = 8;
|
||||
|
||||
export interface ReverseEngineeringServiceOptions {
|
||||
readonly environment?: Readonly<NodeJS.ProcessEnv>;
|
||||
readonly commands: ReverseEngineeringCommandPort;
|
||||
readonly run?: ReverseEngineeringCommandRunner;
|
||||
readonly runOwned?: typeof runOwnedCommand;
|
||||
readonly digest?: typeof digestFile;
|
||||
readonly stat?: (path: string) => Promise<Stats>;
|
||||
}
|
||||
|
||||
interface ReverseEngineeringCommandRunner {
|
||||
(
|
||||
command: string,
|
||||
arguments_: readonly string[],
|
||||
options?: Parameters<typeof execFileOutput>[2],
|
||||
): Promise<{
|
||||
readonly stdout: string;
|
||||
readonly stderr: string;
|
||||
readonly stdoutInvalidUtf8?: boolean;
|
||||
readonly stderrInvalidUtf8?: boolean;
|
||||
readonly stdoutBytes?: number;
|
||||
readonly stderrBytes?: number;
|
||||
}>;
|
||||
}
|
||||
|
||||
export type ReverseEngineeringServiceRuntimeOptions = Omit<
|
||||
ReverseEngineeringServiceOptions,
|
||||
"commands" | "environment"
|
||||
>;
|
||||
|
||||
/** Shared application workflow for caller-selected local analysis commands. */
|
||||
export class ReverseEngineeringService {
|
||||
readonly #environment: Readonly<NodeJS.ProcessEnv>;
|
||||
readonly #commands: ReverseEngineeringCommandPort;
|
||||
readonly #run: ReverseEngineeringCommandRunner;
|
||||
readonly #digest: typeof digestFile;
|
||||
readonly #stat: (path: string) => Promise<Stats>;
|
||||
readonly #pendingCleanup = new Map<
|
||||
ProviderProcessSupervisor,
|
||||
{
|
||||
readonly observation: AnalysisCleanupObservation;
|
||||
readonly failures: Set<AnalysisError>;
|
||||
readonly retryFailure?: AnalysisError;
|
||||
}
|
||||
>();
|
||||
readonly #activeOperations = new Map<
|
||||
AbortController,
|
||||
Promise<Result<Evidence, AnalysisError>>
|
||||
>();
|
||||
#closing = false;
|
||||
#closePromise: Promise<void> | undefined;
|
||||
|
||||
constructor(options: ReverseEngineeringServiceOptions) {
|
||||
this.#environment = { ...(options.environment ?? process.env) };
|
||||
this.#commands = options.commands;
|
||||
this.#run =
|
||||
options.run ??
|
||||
runOwnedCommandOutput(
|
||||
options.runOwned ?? runOwnedCommand,
|
||||
(owner, cleanup) =>
|
||||
this.#pendingCleanup.set(owner, {
|
||||
observation: cleanup,
|
||||
failures: this.#pendingCleanup.get(owner)?.failures ?? new Set(),
|
||||
}),
|
||||
);
|
||||
this.#digest = options.digest ?? digestFile;
|
||||
this.#stat = options.stat ?? statPath;
|
||||
}
|
||||
|
||||
/** Ask retained process owners to settle cleanup before admitting more work. */
|
||||
async retryCleanup(
|
||||
operation = "reverse_engineering_cleanup",
|
||||
): Promise<Result<null, AnalysisError>> {
|
||||
let failure: AnalysisError | undefined;
|
||||
for (const [owner, retained] of this.#pendingCleanup) {
|
||||
let stopped: Awaited<ReturnType<ProviderProcessSupervisor["stop"]>>;
|
||||
try {
|
||||
stopped = await owner.stop();
|
||||
} catch (cause) {
|
||||
stopped = {
|
||||
status: "incomplete",
|
||||
reason: cause instanceof Error ? cause.message : String(cause),
|
||||
};
|
||||
}
|
||||
if (stopped.status === "incomplete") {
|
||||
const observation = {
|
||||
reason: stopped.reason,
|
||||
resources: retained.observation.resources,
|
||||
};
|
||||
let retryFailure = retained.retryFailure;
|
||||
if (retryFailure === undefined) {
|
||||
retryFailure = new AnalysisOutputError(
|
||||
operation,
|
||||
"A previous provider process still requires cleanup.",
|
||||
{ cleanup: observation },
|
||||
);
|
||||
retained.failures.add(retryFailure);
|
||||
}
|
||||
this.#pendingCleanup.set(owner, {
|
||||
observation,
|
||||
failures: retained.failures,
|
||||
retryFailure,
|
||||
});
|
||||
if (failure === undefined) failure = retryFailure;
|
||||
} else {
|
||||
for (const previous of retained.failures) previous.resolveCleanup();
|
||||
this.#pendingCleanup.delete(owner);
|
||||
}
|
||||
}
|
||||
return failure === undefined ? ok(null) : err(failure);
|
||||
}
|
||||
|
||||
/** Retry cleanup for provider process groups retained after an incomplete stop. */
|
||||
close(): Promise<void> {
|
||||
this.#closing = true;
|
||||
this.#closePromise ??= this.#closeActiveOperationsAndCleanup().catch(
|
||||
(cause: unknown) => {
|
||||
this.#closePromise = undefined;
|
||||
throw cause;
|
||||
},
|
||||
);
|
||||
return this.#closePromise;
|
||||
}
|
||||
|
||||
async #closeActiveOperationsAndCleanup(): Promise<void> {
|
||||
const active = [...this.#activeOperations.entries()];
|
||||
for (const [controller] of active) controller.abort();
|
||||
await Promise.allSettled(active.map(([, operation]) => operation));
|
||||
|
||||
const cleanup = await this.retryCleanup();
|
||||
if (!cleanup.ok) throw cleanup.error;
|
||||
}
|
||||
|
||||
inspectWithObjdump(
|
||||
rawInput: unknown,
|
||||
options: { readonly signal?: AbortSignal } = {},
|
||||
): Promise<Result<Evidence, AnalysisError>> {
|
||||
const parsed = objdumpInputSchema.safeParse(rawInput);
|
||||
if (!parsed.success)
|
||||
return Promise.resolve(
|
||||
err(new AnalysisInputError("inspect_with_objdump")),
|
||||
);
|
||||
const input = parsed.data;
|
||||
const command = this.#commands.objdump(input);
|
||||
return this.#observe(
|
||||
"inspect_with_objdump",
|
||||
command,
|
||||
input.operation === "architectures" ? undefined : input.path,
|
||||
input,
|
||||
options.signal,
|
||||
);
|
||||
}
|
||||
|
||||
executeRizinCommand(
|
||||
rawInput: unknown,
|
||||
options: { readonly signal?: AbortSignal } = {},
|
||||
): Promise<Result<Evidence, AnalysisError>> {
|
||||
const parsed = rizinInputSchema.safeParse(rawInput);
|
||||
if (!parsed.success)
|
||||
return Promise.resolve(
|
||||
err(new AnalysisInputError("execute_rizin_command")),
|
||||
);
|
||||
const input = parsed.data;
|
||||
const command = this.#commands.rizin(input);
|
||||
return this.#observe(
|
||||
"execute_rizin_command",
|
||||
command,
|
||||
input.path,
|
||||
input,
|
||||
options.signal,
|
||||
);
|
||||
}
|
||||
|
||||
#observe(
|
||||
operation: ReverseEngineeringOperation,
|
||||
profile: ReverseEngineeringProviderCommand,
|
||||
path: string | undefined,
|
||||
parameters: Readonly<Record<string, JsonValue>>,
|
||||
signal?: AbortSignal,
|
||||
): Promise<Result<Evidence, AnalysisError>> {
|
||||
if (signal?.aborted)
|
||||
return Promise.resolve(err(new AnalysisCancelledError(operation)));
|
||||
if (this.#closing)
|
||||
return Promise.resolve(
|
||||
err(
|
||||
new AnalysisOutputError(
|
||||
operation,
|
||||
"Reverse-engineering service is closed.",
|
||||
),
|
||||
),
|
||||
);
|
||||
if (
|
||||
this.#activeOperations.size >=
|
||||
MAX_CONCURRENT_REVERSE_ENGINEERING_OPERATIONS
|
||||
)
|
||||
return Promise.resolve(
|
||||
err(
|
||||
new AnalysisResourceConstraintError(
|
||||
operation,
|
||||
"transport",
|
||||
"Concurrent reverse-engineering operation capacity reached.",
|
||||
{
|
||||
max_concurrent_operations:
|
||||
MAX_CONCURRENT_REVERSE_ENGINEERING_OPERATIONS,
|
||||
active_operations: this.#activeOperations.size,
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
const controller = new AbortController();
|
||||
const relayAbort = (): void => controller.abort(signal?.reason);
|
||||
if (signal instanceof AbortSignal) {
|
||||
if (signal.aborted) relayAbort();
|
||||
else signal.addEventListener("abort", relayAbort, { once: true });
|
||||
}
|
||||
const operationSignal =
|
||||
signal === undefined || signal instanceof AbortSignal
|
||||
? controller.signal
|
||||
: signal;
|
||||
const running = this.#observeProvider(
|
||||
operation,
|
||||
profile,
|
||||
path,
|
||||
parameters,
|
||||
operationSignal,
|
||||
);
|
||||
const tracked = running.finally(() => {
|
||||
signal?.removeEventListener("abort", relayAbort);
|
||||
this.#activeOperations.delete(controller);
|
||||
});
|
||||
this.#activeOperations.set(controller, tracked);
|
||||
return tracked;
|
||||
}
|
||||
|
||||
async #observeProvider(
|
||||
operation: ReverseEngineeringOperation,
|
||||
profile: ReverseEngineeringProviderCommand,
|
||||
path: string | undefined,
|
||||
parameters: Readonly<Record<string, JsonValue>>,
|
||||
signal?: AbortSignal,
|
||||
): Promise<Result<Evidence, AnalysisError>> {
|
||||
const { executable: command, arguments: args } = profile;
|
||||
if (signal?.aborted) return err(new AnalysisCancelledError(operation));
|
||||
const cleanup = await this.retryCleanup(operation);
|
||||
if (!cleanup.ok) return cleanup;
|
||||
const absolutePath = path === undefined ? undefined : resolve(path);
|
||||
let digest: string | undefined;
|
||||
try {
|
||||
if (absolutePath !== undefined)
|
||||
digest = await this.#digest(absolutePath, signal, this.#stat);
|
||||
} catch (cause) {
|
||||
const code = (cause as NodeJS.ErrnoException).code;
|
||||
if (signal?.aborted)
|
||||
return err(new AnalysisCancelledError(operation, { cause }));
|
||||
if (code === "EACCES" || code === "EPERM") {
|
||||
const deniedPath = absolutePath === undefined ? command : absolutePath;
|
||||
return err(
|
||||
new AnalysisAccessDeniedError(operation, deniedPath, code, {
|
||||
cause,
|
||||
}),
|
||||
);
|
||||
}
|
||||
if (code === "ETIMEDOUT")
|
||||
return err(
|
||||
new AnalysisTimeoutError(operation, DEFAULT_TIMEOUT_MS, { cause }),
|
||||
);
|
||||
return err(new AnalysisInputError(operation, { cause }));
|
||||
}
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
let stdoutBytes = 0;
|
||||
let stderrBytes = 0;
|
||||
let exitCode: number | string | null = 0;
|
||||
let signalName: string | null = null;
|
||||
let outputTruncated = false;
|
||||
let stdoutInvalidUtf8 = false;
|
||||
let stderrInvalidUtf8 = false;
|
||||
let cleanupIncomplete = false;
|
||||
let cleanupOnlyFailure = false;
|
||||
let completionStatus: "complete" | "unknown" = "complete";
|
||||
let failure: AnalysisError | undefined;
|
||||
let cleanupOwner: ProviderProcessSupervisor | undefined;
|
||||
let earlyFailure: AnalysisError | undefined;
|
||||
try {
|
||||
const output = await this.#run(command, args, {
|
||||
env: {
|
||||
...this.#environment,
|
||||
...profile.environmentOverrides,
|
||||
},
|
||||
signal,
|
||||
timeout: DEFAULT_TIMEOUT_MS,
|
||||
stopSignal: "SIGTERM",
|
||||
maxBuffer: MAX_COMBINED_OUTPUT_BYTES,
|
||||
});
|
||||
stdout = output.stdout;
|
||||
stderr = output.stderr;
|
||||
stdoutBytes = output.stdoutBytes ?? Buffer.byteLength(stdout);
|
||||
stderrBytes = output.stderrBytes ?? Buffer.byteLength(stderr);
|
||||
stdoutInvalidUtf8 = output.stdoutInvalidUtf8 === true;
|
||||
stderrInvalidUtf8 = output.stderrInvalidUtf8 === true;
|
||||
} catch (cause) {
|
||||
cleanupOwner =
|
||||
cause instanceof OwnedOutputFailure ? cause.cleanupOwner : undefined;
|
||||
if (
|
||||
cause instanceof OwnedOutputFailure &&
|
||||
cause.capabilityError !== undefined
|
||||
)
|
||||
earlyFailure = new AnalysisCapabilityUnavailableError(
|
||||
cause.capabilityError.providerId,
|
||||
cause.capabilityError.operation,
|
||||
cause.capabilityError.reason,
|
||||
{
|
||||
cause: cause.capabilityError,
|
||||
...(cause.capabilityError.diagnostics === undefined
|
||||
? {}
|
||||
: { diagnostics: cause.capabilityError.diagnostics }),
|
||||
...(cause.capabilityError.capturedOutput === undefined
|
||||
? {}
|
||||
: { capturedOutput: cause.capabilityError.capturedOutput }),
|
||||
...(cause.capabilityError.partialObservation === undefined
|
||||
? {}
|
||||
: {
|
||||
partialObservation: cause.capabilityError.partialObservation,
|
||||
}),
|
||||
...(cause.capabilityError.userMessage === undefined
|
||||
? {}
|
||||
: { userMessage: cause.capabilityError.userMessage }),
|
||||
retryable: cause.capabilityError.retryable,
|
||||
...ownedCleanupOptions(cause),
|
||||
},
|
||||
);
|
||||
const capture = execFileOutputFailure(cause);
|
||||
const code =
|
||||
capture?.code ?? (cause as NodeJS.ErrnoException).code ?? undefined;
|
||||
if (earlyFailure === undefined && code === "E2BIG")
|
||||
earlyFailure = new AnalysisResourceConstraintError(
|
||||
operation,
|
||||
"transport",
|
||||
"The host rejected the provider command because its argument list exceeds the process launch limit.",
|
||||
{
|
||||
argument_bytes: args.reduce(
|
||||
(bytes, argument, index) =>
|
||||
bytes + Buffer.byteLength(argument) + (index === 0 ? 0 : 1),
|
||||
0,
|
||||
),
|
||||
},
|
||||
{ cause, ...ownedCleanupOptions(cause) },
|
||||
);
|
||||
if (
|
||||
earlyFailure === undefined &&
|
||||
(capture === undefined ||
|
||||
code === "ENOENT" ||
|
||||
code === "ERR_PROVIDER_LAUNCH")
|
||||
) {
|
||||
if (signal?.aborted || code === "ABORT_ERR")
|
||||
earlyFailure = new AnalysisCancelledError(operation, {
|
||||
cause,
|
||||
...ownedCleanupOptions(cause),
|
||||
});
|
||||
else if (code === "ETIMEDOUT")
|
||||
earlyFailure = new AnalysisTimeoutError(
|
||||
operation,
|
||||
DEFAULT_TIMEOUT_MS,
|
||||
{
|
||||
cause,
|
||||
...ownedCleanupOptions(cause),
|
||||
},
|
||||
);
|
||||
else if (code === "EACCES" || code === "EPERM")
|
||||
earlyFailure = new AnalysisAccessDeniedError(
|
||||
operation,
|
||||
command,
|
||||
code,
|
||||
{
|
||||
cause,
|
||||
...ownedCleanupOptions(cause),
|
||||
},
|
||||
);
|
||||
else
|
||||
earlyFailure = new AnalysisCapabilityUnavailableError(
|
||||
operation === "inspect_with_objdump" ? "objdump" : "rizin",
|
||||
operation,
|
||||
`Unable to run configured command (${String(code ?? "unknown error")})`,
|
||||
{ cause, ...ownedCleanupOptions(cause) },
|
||||
);
|
||||
}
|
||||
if (earlyFailure === undefined && capture !== undefined) {
|
||||
stdout = capture.stdout;
|
||||
stderr = capture.stderr;
|
||||
stdoutBytes = capture.stdoutBytes ?? Buffer.byteLength(stdout);
|
||||
stderrBytes = capture.stderrBytes ?? Buffer.byteLength(stderr);
|
||||
exitCode = capture.code;
|
||||
signalName = capture.signal;
|
||||
outputTruncated = capture.outputTruncated;
|
||||
if (capture.collectionFailure) {
|
||||
exitCode = null;
|
||||
completionStatus = "unknown";
|
||||
}
|
||||
const capturedOutput = {
|
||||
stdout,
|
||||
stderr,
|
||||
truncated: outputTruncated,
|
||||
stdout_bytes: stdoutBytes,
|
||||
stderr_bytes: stderrBytes,
|
||||
exit_code:
|
||||
!capture.collectionFailure && typeof capture.code === "number"
|
||||
? capture.code
|
||||
: null,
|
||||
signal: capture.signal,
|
||||
};
|
||||
if (signal?.aborted || code === "ABORT_ERR")
|
||||
failure = new AnalysisCancelledError(operation, {
|
||||
cause,
|
||||
capturedOutput,
|
||||
...ownedCleanupOptions(cause),
|
||||
});
|
||||
else if (code === "ETIMEDOUT")
|
||||
failure = new AnalysisTimeoutError(operation, DEFAULT_TIMEOUT_MS, {
|
||||
cause,
|
||||
capturedOutput,
|
||||
...ownedCleanupOptions(cause),
|
||||
});
|
||||
else if (code === "EACCES" || code === "EPERM")
|
||||
failure = new AnalysisAccessDeniedError(operation, command, code, {
|
||||
cause,
|
||||
capturedOutput,
|
||||
...ownedCleanupOptions(cause),
|
||||
});
|
||||
if (
|
||||
cause instanceof OwnedOutputFailure &&
|
||||
cause.cleanup !== undefined
|
||||
) {
|
||||
cleanupIncomplete = true;
|
||||
if (failure === undefined) {
|
||||
cleanupOnlyFailure =
|
||||
!capture.collectionFailure &&
|
||||
!capture.outputTruncated &&
|
||||
!capture.killed &&
|
||||
capture.signal === null &&
|
||||
typeof capture.code === "number" &&
|
||||
!signal?.aborted;
|
||||
failure = new AnalysisOutputError(
|
||||
operation,
|
||||
"Provider process cleanup remains incomplete.",
|
||||
{
|
||||
cause,
|
||||
capturedOutput,
|
||||
cleanup: cause.cleanup,
|
||||
cleanupOnly: cleanupOnlyFailure,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
if (
|
||||
failure === undefined &&
|
||||
cause instanceof OwnedOutputFailure &&
|
||||
cause.collectionFailure
|
||||
)
|
||||
failure = new AnalysisOutputError(
|
||||
operation,
|
||||
"Provider output collection failed.",
|
||||
{ cause, capturedOutput },
|
||||
);
|
||||
if (
|
||||
capture.killed ||
|
||||
capture.signal !== null ||
|
||||
typeof capture.code !== "number"
|
||||
) {
|
||||
exitCode = null;
|
||||
completionStatus = "unknown";
|
||||
}
|
||||
if (cause instanceof OwnedOutputFailure) {
|
||||
stdoutInvalidUtf8 = cause.stdoutInvalidUtf8;
|
||||
stderrInvalidUtf8 = cause.stderrInvalidUtf8;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (earlyFailure !== undefined)
|
||||
return err(this.#registerCleanupFailure(earlyFailure, cleanupOwner));
|
||||
if (failure !== undefined && cleanupOwner !== undefined)
|
||||
failure = this.#registerCleanupFailure(failure, cleanupOwner);
|
||||
let digestAfter: string | null = null;
|
||||
if (absolutePath === undefined) {
|
||||
digestAfter = null;
|
||||
} else if (cleanupIncomplete) {
|
||||
if (!cleanupOnlyFailure) completionStatus = "unknown";
|
||||
} else {
|
||||
try {
|
||||
digestAfter = await this.#digest(absolutePath, signal, this.#stat);
|
||||
} catch {
|
||||
// Preserve the provider's collected observations if the artifact vanished or became unreadable.
|
||||
}
|
||||
}
|
||||
if (signal?.aborted && failure === undefined) {
|
||||
failure = new AnalysisCancelledError(operation, {
|
||||
capturedOutput: {
|
||||
stdout,
|
||||
stderr,
|
||||
truncated: outputTruncated,
|
||||
stdout_bytes: stdoutBytes,
|
||||
stderr_bytes: stderrBytes,
|
||||
exit_code: typeof exitCode === "number" ? exitCode : null,
|
||||
signal: signalName,
|
||||
},
|
||||
});
|
||||
exitCode = null;
|
||||
completionStatus = "unknown";
|
||||
}
|
||||
const artifactChanged =
|
||||
absolutePath === undefined
|
||||
? null
|
||||
: digestAfter === null || digest === undefined
|
||||
? null
|
||||
: digestAfter !== digest;
|
||||
const result = {
|
||||
stdout,
|
||||
stderr,
|
||||
exit_code: exitCode,
|
||||
signal: signalName,
|
||||
output_truncated: outputTruncated,
|
||||
completion_status: completionStatus,
|
||||
artifact_sha256_after: absolutePath === undefined ? null : digestAfter,
|
||||
artifact_changed: absolutePath === undefined ? null : artifactChanged,
|
||||
};
|
||||
const limitations = [
|
||||
...(absolutePath === undefined
|
||||
? [
|
||||
"The objdump version is not reported; the output lists BFD targets advertised by the installed tool and does not establish support for the selected artifact.",
|
||||
]
|
||||
: [
|
||||
"The executable version and supported formats are reported as unknown because no reliable version probe was performed.",
|
||||
]),
|
||||
"A non-zero process exit is preserved as an observed command result.",
|
||||
...(operation === "inspect_with_objdump" &&
|
||||
parameters.follow_debug_links === true
|
||||
? [
|
||||
"DWARF output may include local debug-link files whose identities are not bound to Evidence.",
|
||||
]
|
||||
: []),
|
||||
...(operation === "execute_rizin_command"
|
||||
? [
|
||||
"Rizin commands and retained plugins may read files or access network resources outside the selected artifact; those external inputs are not bound to Evidence.",
|
||||
]
|
||||
: []),
|
||||
...(stdoutInvalidUtf8 || stderrInvalidUtf8
|
||||
? [
|
||||
`Captured ${[
|
||||
...(stdoutInvalidUtf8 ? ["stdout"] : []),
|
||||
...(stderrInvalidUtf8 ? ["stderr"] : []),
|
||||
].join(
|
||||
" and ",
|
||||
)} bytes contain invalid or incomplete UTF-8; replacement characters in the text capture do not preserve the original bytes.`,
|
||||
]
|
||||
: []),
|
||||
...(outputTruncated
|
||||
? [
|
||||
"The provider command ended before completion could be confirmed; captured output is partial.",
|
||||
]
|
||||
: []),
|
||||
...(signalName === null
|
||||
? []
|
||||
: [
|
||||
`The provider process terminated by signal ${signalName}; captured output may be partial and command completion is unknown.`,
|
||||
]),
|
||||
...(failure === undefined ||
|
||||
(failure instanceof AnalysisOutputError && failure.cleanupOnly)
|
||||
? []
|
||||
: [
|
||||
`Command completion is unknown because the operation failed with ${failure._tag}: ${failure.message}`,
|
||||
]),
|
||||
...(absolutePath === undefined
|
||||
? [
|
||||
"This output lists BFD targets advertised by the installed objdump and is independent of the selected artifact.",
|
||||
]
|
||||
: [
|
||||
"When both are available, pre-run and post-run digests detect artifact changes across the provider call but cannot prove the exact bytes consumed during the call.",
|
||||
]),
|
||||
...(artifactChanged === true
|
||||
? [
|
||||
"The provider command may have changed the artifact while analysis was running; pre-run and post-run digests are both reported.",
|
||||
]
|
||||
: []),
|
||||
...(absolutePath !== undefined && digestAfter === null
|
||||
? [
|
||||
cleanupIncomplete
|
||||
? "The post-run artifact digest was not captured because owned provider cleanup was incomplete at observation time; artifact changes during the command remain unquantified."
|
||||
: signal?.aborted
|
||||
? "The post-run artifact digest is unknown because cancellation interrupted the digest."
|
||||
: "The post-run artifact digest is unknown because the artifact could not be read after the provider call.",
|
||||
]
|
||||
: []),
|
||||
];
|
||||
const evidence = createEvidence(
|
||||
absolutePath === undefined || digest === undefined
|
||||
? undefined
|
||||
: { path: absolutePath, sha256: digest, format: "unknown" },
|
||||
profile.identity,
|
||||
{
|
||||
predicateType: "rea.reverse-engineering.command-observation",
|
||||
operation,
|
||||
parameters: {
|
||||
...(absolutePath === undefined
|
||||
? { operation: "architectures" }
|
||||
: parameters),
|
||||
provider_executable: command,
|
||||
command_arguments: [...args],
|
||||
},
|
||||
rawResult: result,
|
||||
result,
|
||||
limitations,
|
||||
...(absolutePath === undefined
|
||||
? {}
|
||||
: {
|
||||
locations: [
|
||||
{ kind: "artifact-path" as const, path: absolutePath },
|
||||
],
|
||||
}),
|
||||
},
|
||||
);
|
||||
return failure === undefined
|
||||
? ok(evidence)
|
||||
: err(failure.retainPartialObservation(evidence));
|
||||
}
|
||||
|
||||
#registerCleanupFailure(
|
||||
failure: AnalysisError,
|
||||
cleanupOwner: ProviderProcessSupervisor | undefined,
|
||||
): AnalysisError {
|
||||
if (cleanupOwner !== undefined && failure.cleanupIncomplete)
|
||||
this.#pendingCleanup.get(cleanupOwner)?.failures.add(failure);
|
||||
return failure;
|
||||
}
|
||||
}
|
||||
|
||||
interface OwnedOutputFailureOptions {
|
||||
readonly stdout: string;
|
||||
readonly stderr: string;
|
||||
readonly code: number | string | null;
|
||||
readonly signal: string | null;
|
||||
readonly outputTruncated: boolean;
|
||||
readonly stdoutInvalidUtf8: boolean;
|
||||
readonly stderrInvalidUtf8: boolean;
|
||||
readonly stdoutBytes: number;
|
||||
readonly stderrBytes: number;
|
||||
readonly capabilityError?: AnalysisCapabilityUnavailableError;
|
||||
readonly collectionFailure?: boolean;
|
||||
readonly cleanup?: AnalysisCleanupObservation;
|
||||
readonly cleanupOwner?: ProviderProcessSupervisor;
|
||||
readonly cause?: unknown;
|
||||
}
|
||||
|
||||
class OwnedOutputFailure extends Error {
|
||||
readonly stdout: string;
|
||||
readonly stderr: string;
|
||||
readonly code: number | string | null;
|
||||
readonly signal: string | null;
|
||||
readonly outputTruncated: boolean;
|
||||
readonly stdoutInvalidUtf8: boolean;
|
||||
readonly stderrInvalidUtf8: boolean;
|
||||
readonly stdoutBytes: number;
|
||||
readonly stderrBytes: number;
|
||||
readonly capabilityError: AnalysisCapabilityUnavailableError | undefined;
|
||||
readonly collectionFailure: boolean;
|
||||
readonly cleanup: AnalysisCleanupObservation | undefined;
|
||||
readonly cleanupOwner: ProviderProcessSupervisor | undefined;
|
||||
|
||||
constructor(options: OwnedOutputFailureOptions) {
|
||||
super("Owned provider command did not produce a complete result.", {
|
||||
cause: options.cause,
|
||||
});
|
||||
this.stdout = options.stdout;
|
||||
this.stderr = options.stderr;
|
||||
this.code = options.code;
|
||||
this.signal = options.signal;
|
||||
this.outputTruncated = options.outputTruncated;
|
||||
this.stdoutInvalidUtf8 = options.stdoutInvalidUtf8;
|
||||
this.stderrInvalidUtf8 = options.stderrInvalidUtf8;
|
||||
this.stdoutBytes = options.stdoutBytes;
|
||||
this.stderrBytes = options.stderrBytes;
|
||||
this.capabilityError = options.capabilityError;
|
||||
this.collectionFailure = options.collectionFailure ?? false;
|
||||
this.cleanup = options.cleanup;
|
||||
this.cleanupOwner = options.cleanupOwner;
|
||||
}
|
||||
}
|
||||
|
||||
const runOwnedCommandOutput =
|
||||
(
|
||||
runOwned: typeof runOwnedCommand,
|
||||
onCleanupOwner: (
|
||||
owner: ProviderProcessSupervisor,
|
||||
cleanup: AnalysisCleanupObservation,
|
||||
) => void,
|
||||
): ReverseEngineeringCommandRunner =>
|
||||
async (command, args, options = {}) => {
|
||||
const spawnEnv = { ...options.env };
|
||||
try {
|
||||
const snapshot = await runOwned(
|
||||
{
|
||||
command,
|
||||
arguments: [...args],
|
||||
runId: randomUUID(),
|
||||
expectedCommand: null,
|
||||
env: spawnEnv,
|
||||
hostEnvironment: {},
|
||||
sweepTokenOwnedProcesses: true,
|
||||
},
|
||||
{
|
||||
timeoutMs: DEFAULT_TIMEOUT_MS,
|
||||
diagnosticBytes: MAX_COMBINED_OUTPUT_BYTES,
|
||||
acceptNonZeroExit: true,
|
||||
},
|
||||
{ signal: options.signal },
|
||||
);
|
||||
if (snapshot.signal !== null && snapshot.signal !== undefined)
|
||||
throw new OwnedOutputFailure({
|
||||
stdout: snapshot.stdout.text,
|
||||
stderr: snapshot.stderr.text,
|
||||
code: snapshot.exitCode ?? null,
|
||||
signal: snapshot.signal,
|
||||
outputTruncated: snapshot.diagnosticTruncated === true,
|
||||
stdoutInvalidUtf8: snapshot.stdout.invalidUtf8 === true,
|
||||
stderrInvalidUtf8: snapshot.stderr.invalidUtf8 === true,
|
||||
stdoutBytes: snapshot.stdout.bytes,
|
||||
stderrBytes: snapshot.stderr.bytes,
|
||||
});
|
||||
if (snapshot.exitCode !== 0)
|
||||
throw new OwnedOutputFailure({
|
||||
stdout: snapshot.stdout.text,
|
||||
stderr: snapshot.stderr.text,
|
||||
code: snapshot.exitCode ?? null,
|
||||
signal: null,
|
||||
outputTruncated: snapshot.diagnosticTruncated === true,
|
||||
stdoutInvalidUtf8: snapshot.stdout.invalidUtf8 === true,
|
||||
stderrInvalidUtf8: snapshot.stderr.invalidUtf8 === true,
|
||||
stdoutBytes: snapshot.stdout.bytes,
|
||||
stderrBytes: snapshot.stderr.bytes,
|
||||
});
|
||||
return {
|
||||
stdout: snapshot.stdout.text,
|
||||
stderr: snapshot.stderr.text,
|
||||
stdoutInvalidUtf8: snapshot.stdout.invalidUtf8 === true,
|
||||
stderrInvalidUtf8: snapshot.stderr.invalidUtf8 === true,
|
||||
stdoutBytes: snapshot.stdout.bytes,
|
||||
stderrBytes: snapshot.stderr.bytes,
|
||||
};
|
||||
} catch (cause) {
|
||||
if (!(cause instanceof OwnedCommandFailure)) throw cause;
|
||||
if (cause.cleanupOwner !== undefined && cause.cleanupFailure !== null)
|
||||
onCleanupOwner(cause.cleanupOwner, {
|
||||
reason: cause.cleanupFailure,
|
||||
resources: cause.resources,
|
||||
});
|
||||
const snapshot = cause.snapshot;
|
||||
const launchErrorCode =
|
||||
cause.cause instanceof Error
|
||||
? Reflect.get(cause.cause, "code")
|
||||
: undefined;
|
||||
const code =
|
||||
cause.reason === "cancelled"
|
||||
? "ABORT_ERR"
|
||||
: cause.reason === "timeout"
|
||||
? "ETIMEDOUT"
|
||||
: cause.reason === "output-limit"
|
||||
? "ERR_CHILD_PROCESS_STDIO_MAXBUFFER"
|
||||
: launchErrorCode === "E2BIG"
|
||||
? "E2BIG"
|
||||
: snapshot === null
|
||||
? "ERR_PROVIDER_LAUNCH"
|
||||
: (snapshot.exitCode ?? null);
|
||||
throw new OwnedOutputFailure({
|
||||
stdout: snapshot?.stdout.text ?? "",
|
||||
stderr: snapshot?.stderr.text ?? "",
|
||||
code,
|
||||
signal: snapshot?.signal ?? null,
|
||||
outputTruncated: snapshot?.diagnosticTruncated === true,
|
||||
stdoutInvalidUtf8: snapshot?.stdout.invalidUtf8 === true,
|
||||
stderrInvalidUtf8: snapshot?.stderr.invalidUtf8 === true,
|
||||
stdoutBytes: snapshot?.stdout.bytes ?? 0,
|
||||
stderrBytes: snapshot?.stderr.bytes ?? 0,
|
||||
...(cause.cause instanceof AnalysisCapabilityUnavailableError
|
||||
? { capabilityError: cause.cause }
|
||||
: {}),
|
||||
...(cause.collectionFailure ? { collectionFailure: true } : {}),
|
||||
...(cause.cleanupFailure === null
|
||||
? {}
|
||||
: {
|
||||
cleanup: {
|
||||
reason: cause.cleanupFailure,
|
||||
resources: cause.resources,
|
||||
},
|
||||
}),
|
||||
...(cause.cleanupOwner === undefined
|
||||
? {}
|
||||
: { cleanupOwner: cause.cleanupOwner }),
|
||||
cause,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const ownedCleanupOptions = (
|
||||
cause: unknown,
|
||||
): { readonly cleanup?: AnalysisCleanupObservation } =>
|
||||
cause instanceof OwnedOutputFailure && cause.cleanup !== undefined
|
||||
? { cleanup: cause.cleanup }
|
||||
: {};
|
||||
|
||||
const digestFile = async (
|
||||
path: string,
|
||||
signal?: AbortSignal,
|
||||
stat: (path: string) => Promise<Stats> = statPath,
|
||||
): Promise<string> => {
|
||||
const info = await statWithDeadline(path, signal, stat);
|
||||
if (!info.isFile())
|
||||
throw Object.assign(new Error("Artifact digest requires a regular file"), {
|
||||
code: "EINVAL",
|
||||
});
|
||||
return new Promise<string>((resolveDigest, rejectDigest) => {
|
||||
const hash = createHash("sha256");
|
||||
const stream = createReadStream(path);
|
||||
const timer = setTimeout(
|
||||
() =>
|
||||
stream.destroy(
|
||||
Object.assign(new Error("Artifact digest timed out"), {
|
||||
code: "ETIMEDOUT",
|
||||
}),
|
||||
),
|
||||
DEFAULT_TIMEOUT_MS,
|
||||
);
|
||||
const abort = (): void => {
|
||||
stream.destroy(new AnalysisCancelledError("artifact_digest"));
|
||||
};
|
||||
const cleanup = (): void => {
|
||||
clearTimeout(timer);
|
||||
signal?.removeEventListener("abort", abort);
|
||||
};
|
||||
if (signal?.aborted) {
|
||||
cleanup();
|
||||
stream.destroy();
|
||||
rejectDigest(new AnalysisCancelledError("artifact_digest"));
|
||||
return;
|
||||
}
|
||||
signal?.addEventListener("abort", abort, { once: true });
|
||||
stream.on("data", (chunk: Buffer | string) => hash.update(chunk));
|
||||
stream.once("error", (cause: Error) => {
|
||||
cleanup();
|
||||
rejectDigest(cause);
|
||||
});
|
||||
stream.once("end", () => {
|
||||
cleanup();
|
||||
resolveDigest(hash.digest("hex"));
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const statWithDeadline = (
|
||||
path: string,
|
||||
signal: AbortSignal | undefined,
|
||||
stat: (path: string) => Promise<Stats>,
|
||||
): Promise<Stats> => {
|
||||
if (signal?.aborted)
|
||||
return Promise.reject(new AnalysisCancelledError("artifact_digest"));
|
||||
return new Promise<Stats>((resolveStat, rejectStat) => {
|
||||
let settled = false;
|
||||
const finish = (cause?: unknown, value?: Stats): void => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
signal?.removeEventListener("abort", abort);
|
||||
if (cause !== undefined) rejectStat(cause);
|
||||
else resolveStat(value!);
|
||||
};
|
||||
const abort = (): void =>
|
||||
finish(new AnalysisCancelledError("artifact_digest"));
|
||||
const timer = setTimeout(
|
||||
() =>
|
||||
finish(
|
||||
Object.assign(new Error("Artifact stat timed out"), {
|
||||
code: "ETIMEDOUT",
|
||||
}),
|
||||
),
|
||||
DEFAULT_TIMEOUT_MS,
|
||||
);
|
||||
signal?.addEventListener("abort", abort, { once: true });
|
||||
if (signal?.aborted) {
|
||||
abort();
|
||||
return;
|
||||
}
|
||||
// Promise handlers stay attached after timeout/cancellation so a delayed
|
||||
// stat rejection is observed and cannot become an unhandled rejection.
|
||||
try {
|
||||
void stat(path).then(
|
||||
(info) => finish(undefined, info),
|
||||
(cause: unknown) => finish(cause),
|
||||
);
|
||||
} catch (cause) {
|
||||
finish(cause);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
|
@ -49,6 +49,8 @@ import {
|
|||
streamCliCommandResults,
|
||||
type CliResultOutput,
|
||||
} from "./cli/cliResultOutput.js";
|
||||
import { registerReverseEngineeringCommands } from "./cli/reverseEngineeringCommands.js";
|
||||
import { registerCutterCommands } from "./cli/cutterCommands.js";
|
||||
|
||||
/** Describe CLI settings that are invalid before any command runs, as MCP does. */
|
||||
export const cliConfigurationError = (
|
||||
|
|
@ -95,6 +97,8 @@ export const createCli = (
|
|||
});
|
||||
|
||||
registerSetupCommands(cli, logger, environment);
|
||||
registerReverseEngineeringCommands(cli, logger, environment);
|
||||
registerCutterCommands(cli, logger, environment);
|
||||
registerCoreAnalysisCommands(cli, logger, analysis.runDirectAnalysis);
|
||||
registerUtilityCommands(cli, logger, environment, analysis);
|
||||
registerWasmCommands(cli, logger, environment);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,69 @@
|
|||
import { z } from "incur";
|
||||
import type { Logger } from "pino";
|
||||
|
||||
import { CutterBridgeClient } from "../cutter/CutterBridgeClient.js";
|
||||
import { CutterBridgeService } from "../cutter/CutterBridgeService.js";
|
||||
import { CLI_COMMANDS } from "../cliCommandNames.js";
|
||||
import { logCliCommand } from "../cliLogging.js";
|
||||
import { projectAnalysisError } from "../domain/analysisErrorProjection.js";
|
||||
import { withCommandCancellation } from "./commandCancellation.js";
|
||||
import type { CliInstance } from "./types.js";
|
||||
|
||||
/** Register commands that control an already-running Cutter instance. */
|
||||
export const registerCutterCommands = (
|
||||
cli: CliInstance,
|
||||
logger: Logger,
|
||||
environment: Readonly<NodeJS.ProcessEnv>,
|
||||
): void => {
|
||||
const cutter = new CutterBridgeService(new CutterBridgeClient(environment));
|
||||
|
||||
cli.command(CLI_COMMANDS.listCutterSessions, {
|
||||
description:
|
||||
"List live Cutter instances connected through the REA Python plugin bridge",
|
||||
args: z.object({}),
|
||||
run: () =>
|
||||
logCliCommand(logger, CLI_COMMANDS.listCutterSessions, () =>
|
||||
cutter.listSessions(),
|
||||
),
|
||||
});
|
||||
cli.command(CLI_COMMANDS.cutterCommand, {
|
||||
description: "Execute one Rizin command in a selected live Cutter instance",
|
||||
args: z.object({
|
||||
sessionId: z
|
||||
.string()
|
||||
.uuid()
|
||||
.describe("Session ID copied from list-cutter-sessions."),
|
||||
expectedGeneration: z.coerce
|
||||
.number()
|
||||
.int()
|
||||
.nonnegative()
|
||||
.describe("Session generation returned by list-cutter-sessions."),
|
||||
command: z
|
||||
.string()
|
||||
.min(1)
|
||||
.describe("One unrestricted Rizin command to run in Cutter.")
|
||||
.refine((value) => !/[\r\n]/u.test(value)),
|
||||
}),
|
||||
options: z.object({
|
||||
json: z
|
||||
.boolean()
|
||||
.default(false)
|
||||
.describe("Return command-specific JSON output when available."),
|
||||
}),
|
||||
run: ({ args, options }) =>
|
||||
withCommandCancellation((signal) =>
|
||||
logCliCommand(logger, CLI_COMMANDS.cutterCommand, async () => {
|
||||
const result = await cutter.execute(
|
||||
{
|
||||
session_id: args.sessionId,
|
||||
expected_generation: args.expectedGeneration,
|
||||
command: args.command,
|
||||
json: options.json,
|
||||
},
|
||||
{ signal },
|
||||
);
|
||||
return result.ok ? result.value : projectAnalysisError(result.error);
|
||||
}),
|
||||
),
|
||||
});
|
||||
};
|
||||
|
|
@ -0,0 +1,367 @@
|
|||
import { z } from "incur";
|
||||
import type { Logger } from "pino";
|
||||
|
||||
import { createReverseEngineeringService } from "../composition/reverseEngineering.js";
|
||||
import { AnalysisError } from "../domain/analysisErrorBase.js";
|
||||
import {
|
||||
AnalysisInputError,
|
||||
AnalysisOutputError,
|
||||
} from "../domain/analysisErrorCore.js";
|
||||
import { evidenceSchema, type Evidence } from "../domain/evidence.js";
|
||||
import type { Result } from "../domain/result.js";
|
||||
import { err } from "../domain/result.js";
|
||||
import { projectAnalysisError } from "../domain/analysisErrorProjection.js";
|
||||
import { logCliCommand } from "../cliLogging.js";
|
||||
import type { CliInstance } from "./types.js";
|
||||
import { withCommandCancellation } from "./commandCancellation.js";
|
||||
import { CLI_COMMANDS } from "../cliCommandNames.js";
|
||||
import type { ReverseEngineeringService } from "../application/reverse/ReverseEngineeringService.js";
|
||||
import {
|
||||
GdbSessionManager,
|
||||
gdbOneShotCommandMayInvalidateDetachProof,
|
||||
} from "../gdb/GdbSessionManager.js";
|
||||
import { RizinDebugSessionManager } from "../rizin/RizinDebugSessionManager.js";
|
||||
|
||||
const executeWithCleanup = async (
|
||||
service: ReverseEngineeringService,
|
||||
execute: () => Promise<Result<Evidence, AnalysisError>>,
|
||||
): Promise<Result<Evidence, AnalysisError>> => {
|
||||
let result: Result<Evidence, AnalysisError>;
|
||||
try {
|
||||
result = await execute();
|
||||
} catch (cause) {
|
||||
await service.close().catch(() => undefined);
|
||||
throw cause;
|
||||
}
|
||||
const hadIncompleteCleanup = !result.ok && result.error.cleanupIncomplete;
|
||||
try {
|
||||
await service.close();
|
||||
} catch (cause) {
|
||||
if (result.ok) return err(asCleanupError(cause));
|
||||
const cleanup = cause instanceof AnalysisError ? cause.cleanup : undefined;
|
||||
const error = new AnalysisOutputError(
|
||||
"reverse_engineering_cleanup",
|
||||
"Provider cleanup remains incomplete after the command failed.",
|
||||
{
|
||||
cause: result.error,
|
||||
...(cleanup === undefined ? {} : { cleanup }),
|
||||
...(result.error.capturedOutput === undefined
|
||||
? {}
|
||||
: { capturedOutput: result.error.capturedOutput }),
|
||||
...(result.error.partialObservation === undefined
|
||||
? {}
|
||||
: { partialObservation: result.error.partialObservation }),
|
||||
},
|
||||
);
|
||||
return err(error);
|
||||
}
|
||||
if (!result.ok && hadIncompleteCleanup && !result.error.cleanupIncomplete) {
|
||||
const evidence = cleanupOnlyCompleteEvidence(result.error);
|
||||
if (evidence !== undefined) return { ok: true, value: evidence };
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
const cleanupOnlyCompleteEvidence = (
|
||||
error: AnalysisError,
|
||||
): Evidence | undefined => {
|
||||
if (
|
||||
!(error instanceof AnalysisOutputError) ||
|
||||
!error.cleanupOnly ||
|
||||
error.partialObservation === undefined
|
||||
)
|
||||
return undefined;
|
||||
const parsed = evidenceSchema.safeParse(error.partialObservation);
|
||||
if (!parsed.success) return undefined;
|
||||
const normalized = parsed.data.normalized_result;
|
||||
return typeof normalized === "object" &&
|
||||
normalized !== null &&
|
||||
!Array.isArray(normalized) &&
|
||||
normalized.completion_status === "complete"
|
||||
? parsed.data
|
||||
: undefined;
|
||||
};
|
||||
|
||||
const asCleanupError = (cause: unknown): AnalysisError =>
|
||||
cause instanceof AnalysisError
|
||||
? cause
|
||||
: new AnalysisOutputError(
|
||||
"reverse_engineering_cleanup",
|
||||
"Provider process cleanup failed.",
|
||||
{ cause },
|
||||
);
|
||||
|
||||
/** Add one-shot reverse-engineering commands using the shared service layer. */
|
||||
export const registerReverseEngineeringCommands = (
|
||||
cli: CliInstance,
|
||||
logger: Logger,
|
||||
environment: Readonly<NodeJS.ProcessEnv>,
|
||||
createGdbManager: (
|
||||
environment: Readonly<NodeJS.ProcessEnv>,
|
||||
) => GdbSessionManager = (providerEnvironment) =>
|
||||
new GdbSessionManager({ environment: providerEnvironment }),
|
||||
createService: typeof createReverseEngineeringService = createReverseEngineeringService,
|
||||
): void => {
|
||||
const service = createService(environment);
|
||||
cli.command(CLI_COMMANDS.inspectWithObjdump, {
|
||||
description:
|
||||
"Inspect a local artifact with GNU objdump and retain its raw output as Evidence",
|
||||
args: z.object({
|
||||
path: z
|
||||
.string()
|
||||
.min(1)
|
||||
.describe("Path to the local artifact to inspect."),
|
||||
operation: z
|
||||
.enum([
|
||||
"file_headers",
|
||||
"section_headers",
|
||||
"symbols",
|
||||
"relocations",
|
||||
"dwarf",
|
||||
"disassemble",
|
||||
"disassemble_all",
|
||||
])
|
||||
.describe("GNU objdump inspection operation to run."),
|
||||
}),
|
||||
options: z.object({
|
||||
followDebugLinks: z
|
||||
.boolean()
|
||||
.default(false)
|
||||
.describe(
|
||||
"Follow local DWARF debug links. External debug-file identity is not bound to Evidence.",
|
||||
),
|
||||
}),
|
||||
run: ({ args, options }) =>
|
||||
withCommandCancellation((signal) =>
|
||||
logCliCommand(logger, CLI_COMMANDS.inspectWithObjdump, async () => {
|
||||
const result = await executeWithCleanup(service, () =>
|
||||
service.inspectWithObjdump(
|
||||
{
|
||||
...args,
|
||||
follow_debug_links: options.followDebugLinks,
|
||||
},
|
||||
{ signal },
|
||||
),
|
||||
);
|
||||
return result.ok ? result.value : projectAnalysisError(result.error);
|
||||
}),
|
||||
),
|
||||
});
|
||||
cli.command(CLI_COMMANDS.listObjdumpArchitectures, {
|
||||
description: "List BFD targets advertised by the installed GNU objdump",
|
||||
args: z.object({}),
|
||||
run: () =>
|
||||
withCommandCancellation((signal) =>
|
||||
logCliCommand(
|
||||
logger,
|
||||
CLI_COMMANDS.listObjdumpArchitectures,
|
||||
async () => {
|
||||
const result = await executeWithCleanup(service, () =>
|
||||
service.inspectWithObjdump(
|
||||
{ operation: "architectures" },
|
||||
{ signal },
|
||||
),
|
||||
);
|
||||
return result.ok
|
||||
? result.value
|
||||
: projectAnalysisError(result.error);
|
||||
},
|
||||
),
|
||||
),
|
||||
});
|
||||
cli.command(CLI_COMMANDS.executeRizinCommand, {
|
||||
description: "Execute one Rizin command against a local artifact",
|
||||
args: z.object({
|
||||
path: z
|
||||
.string()
|
||||
.min(1)
|
||||
.describe("Path to the local artifact to analyze."),
|
||||
command: z.string().min(1).describe("One caller-selected Rizin command."),
|
||||
}),
|
||||
run: ({ args }) =>
|
||||
withCommandCancellation((signal) =>
|
||||
logCliCommand(logger, CLI_COMMANDS.executeRizinCommand, async () => {
|
||||
const result = await executeWithCleanup(service, () =>
|
||||
service.executeRizinCommand(args, { signal }),
|
||||
);
|
||||
return result.ok ? result.value : projectAnalysisError(result.error);
|
||||
}),
|
||||
),
|
||||
});
|
||||
cli.command(CLI_COMMANDS.debugWithGdb, {
|
||||
description:
|
||||
"Run one unrestricted GDB console command in an ephemeral MI session; provide an inferior TTY so a live target can be safely detached",
|
||||
args: z.object({
|
||||
command: z
|
||||
.string()
|
||||
.min(1)
|
||||
.describe("One unrestricted GDB console command."),
|
||||
}),
|
||||
options: z.object({
|
||||
inferiorTty: z
|
||||
.string()
|
||||
.min(1)
|
||||
.describe(
|
||||
"Caller-selected durable TTY for output from a live GDB inferior.",
|
||||
),
|
||||
}),
|
||||
run: ({ args, options }) =>
|
||||
withCommandCancellation((signal) =>
|
||||
logCliCommand(logger, CLI_COMMANDS.debugWithGdb, async () => {
|
||||
if (gdbOneShotCommandMayInvalidateDetachProof(args.command))
|
||||
return projectAnalysisError(
|
||||
new AnalysisInputError("gdb_console", undefined, [
|
||||
{
|
||||
path: ["command"],
|
||||
reason: "invalid_value",
|
||||
message:
|
||||
"This command can invalidate the verified inferior output route; use the persistent GDB session workflow.",
|
||||
},
|
||||
]),
|
||||
);
|
||||
const manager = createGdbManager(environment);
|
||||
let sessionId: string | undefined;
|
||||
try {
|
||||
const started = await manager.start(signal, {
|
||||
inferiorTty: options.inferiorTty,
|
||||
});
|
||||
if (!started.ok) return projectAnalysisError(started.error);
|
||||
sessionId = started.value.session_id;
|
||||
const executed = await manager.execute(
|
||||
sessionId,
|
||||
args.command,
|
||||
signal,
|
||||
);
|
||||
return executed.ok
|
||||
? executed.value.evidence
|
||||
: projectAnalysisError(executed.error);
|
||||
} finally {
|
||||
let cleanupFailed = false;
|
||||
if (sessionId !== undefined) {
|
||||
try {
|
||||
const closed = await manager.close(sessionId);
|
||||
cleanupFailed = !closed.ok;
|
||||
if (!closed.ok)
|
||||
logger.error(
|
||||
{
|
||||
command: CLI_COMMANDS.debugWithGdb,
|
||||
cleanup: projectAnalysisError(closed.error),
|
||||
},
|
||||
"GDB cleanup failed after collecting command output",
|
||||
);
|
||||
} catch (cause) {
|
||||
cleanupFailed = true;
|
||||
logger.error(
|
||||
{
|
||||
command: CLI_COMMANDS.debugWithGdb,
|
||||
cleanup:
|
||||
cause instanceof Error
|
||||
? cause.message
|
||||
: "Unknown cleanup failure",
|
||||
},
|
||||
"GDB cleanup failed after collecting command output",
|
||||
);
|
||||
}
|
||||
}
|
||||
try {
|
||||
await manager.closeAll();
|
||||
cleanupFailed = false;
|
||||
} catch (cause) {
|
||||
cleanupFailed = true;
|
||||
logger.error(
|
||||
{
|
||||
command: CLI_COMMANDS.debugWithGdb,
|
||||
cleanup:
|
||||
cause instanceof Error
|
||||
? cause.message
|
||||
: "Unknown cleanup failure",
|
||||
},
|
||||
"GDB fallback cleanup failed",
|
||||
);
|
||||
}
|
||||
if (cleanupFailed) process.exitCode = 1;
|
||||
}
|
||||
}),
|
||||
),
|
||||
});
|
||||
cli.command(CLI_COMMANDS.debugWithRizin, {
|
||||
description:
|
||||
"Run one unrestricted Rizin debugger command in an ephemeral session",
|
||||
args: z.object({
|
||||
path: z.string().min(1).describe("Path to the local artifact to debug."),
|
||||
command: z
|
||||
.string()
|
||||
.min(1)
|
||||
.describe("One unrestricted Rizin debugger command.")
|
||||
.refine((value) => !/[\r\n]/u.test(value)),
|
||||
}),
|
||||
options: z.object({
|
||||
inferiorTty: z
|
||||
.string()
|
||||
.min(1)
|
||||
.describe("Caller-selected Linux TTY for inferior stdout and stderr."),
|
||||
backend: z
|
||||
.string()
|
||||
.min(1)
|
||||
.optional()
|
||||
.describe("Optional Rizin IO debugger backend name."),
|
||||
}),
|
||||
run: ({ args, options }) =>
|
||||
withCommandCancellation((signal) =>
|
||||
logCliCommand(logger, CLI_COMMANDS.debugWithRizin, async () => {
|
||||
const manager = new RizinDebugSessionManager(environment);
|
||||
let sessionId: string | undefined;
|
||||
try {
|
||||
const started = await manager.start(
|
||||
{
|
||||
...args,
|
||||
inferior_tty: options.inferiorTty,
|
||||
...(options.backend === undefined
|
||||
? {}
|
||||
: { backend: options.backend }),
|
||||
},
|
||||
signal,
|
||||
);
|
||||
if (!started.ok) return projectAnalysisError(started.error);
|
||||
sessionId = started.value.session_id;
|
||||
const executed = await manager.execute(
|
||||
started.value.session_id,
|
||||
args.command,
|
||||
signal,
|
||||
);
|
||||
return executed.ok
|
||||
? executed.value.evidence
|
||||
: projectAnalysisError(executed.error);
|
||||
} finally {
|
||||
const closed =
|
||||
sessionId === undefined
|
||||
? undefined
|
||||
: await manager.close(sessionId);
|
||||
if (closed !== undefined && !closed.ok) {
|
||||
logger.error(
|
||||
{
|
||||
command: CLI_COMMANDS.debugWithRizin,
|
||||
cleanup: projectAnalysisError(closed.error),
|
||||
},
|
||||
"Rizin cleanup failed after collecting command output",
|
||||
);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
await manager.closeAll().catch((cause: unknown) => {
|
||||
process.exitCode = 1;
|
||||
logger.error(
|
||||
{
|
||||
command: CLI_COMMANDS.debugWithRizin,
|
||||
cleanup:
|
||||
cause instanceof Error
|
||||
? cause.message
|
||||
: "Unknown cleanup failure",
|
||||
},
|
||||
"Rizin fallback cleanup failed",
|
||||
);
|
||||
});
|
||||
}
|
||||
}),
|
||||
),
|
||||
});
|
||||
};
|
||||
|
|
@ -49,6 +49,14 @@ export const CLI_COMMANDS = Object.freeze({
|
|||
inspectManagedArtifact: "inspect-managed-artifact",
|
||||
inspectRecordedCrash: "inspect-recorded-crash",
|
||||
inspectBinaryLayout: "inspect-binary-layout",
|
||||
listCutterSessions: "list-cutter-sessions",
|
||||
cutterCommand: "cutter-command",
|
||||
inspectWithObjdump: "inspect-with-objdump",
|
||||
listObjdumpArchitectures: "list-objdump-architectures",
|
||||
executeRizinCommand: "execute-rizin-command",
|
||||
debugWithGdb: "debug-with-gdb",
|
||||
debugWithRizin: "debug-with-rizin",
|
||||
|
||||
inspectGoBinary: "inspect-go-binary",
|
||||
inspectRustBinary: "inspect-rust-binary",
|
||||
inspectPeResources: "inspect-pe-resources",
|
||||
|
|
@ -164,6 +172,13 @@ export const CLI_COMMAND_TOOL_ALIASES = Object.freeze({
|
|||
search: ["search_strings", "search_procedures"],
|
||||
"capture-process": ["capture_process_scenario"],
|
||||
"compare-bundles": ["compare_bundles"],
|
||||
"list-cutter-sessions": ["list_cutter_sessions"],
|
||||
"cutter-command": ["cutter_command"],
|
||||
"inspect-with-objdump": ["inspect_with_objdump"],
|
||||
"list-objdump-architectures": ["inspect_with_objdump"],
|
||||
"execute-rizin-command": ["execute_rizin_command"],
|
||||
"debug-with-gdb": ["gdb_console"],
|
||||
"debug-with-rizin": ["rizin_debug_command"],
|
||||
"list-frida-devices": ["list_frida_devices"],
|
||||
"list-frida-processes": ["list_frida_processes"],
|
||||
"instrument-with-frida": ["instrument_with_frida"],
|
||||
|
|
@ -224,6 +239,12 @@ export const MCP_TOOLS_WITHOUT_DEDICATED_CLI = Object.freeze([
|
|||
"get_evidence_bundle",
|
||||
"get_navigation_context",
|
||||
"inspect_address_context",
|
||||
"start_gdb_session",
|
||||
"gdb_session_status",
|
||||
"close_gdb_session",
|
||||
"start_rizin_debug_session",
|
||||
"rizin_debug_session_status",
|
||||
"close_rizin_debug_session",
|
||||
"start_frida_session",
|
||||
"load_frida_script",
|
||||
"resume_frida_session",
|
||||
|
|
|
|||
|
|
@ -0,0 +1,77 @@
|
|||
import { existsSync, statSync } from "node:fs";
|
||||
import { win32 } from "node:path";
|
||||
|
||||
export interface WindowsExecutableLookup {
|
||||
/** Windows-formatted working directory; defaults to the process CWD. */
|
||||
readonly cwd?: string;
|
||||
/** Test seam for Windows paths when running on a non-Windows host. */
|
||||
readonly isFile?: (path: string) => boolean;
|
||||
}
|
||||
|
||||
const splitWindowsPath = (value: string): readonly string[] => {
|
||||
const entries: string[] = [];
|
||||
let start = 0;
|
||||
let quoted = false;
|
||||
for (let index = 0; index < value.length; index += 1) {
|
||||
if (value[index] === '"') quoted = !quoted;
|
||||
else if (value[index] === ";" && !quoted) {
|
||||
entries.push(value.slice(start, index));
|
||||
start = index + 1;
|
||||
}
|
||||
}
|
||||
entries.push(value.slice(start));
|
||||
return entries.flatMap((entry) => {
|
||||
if (entry.startsWith('"') && entry.endsWith('"') && entry.length >= 2)
|
||||
return [entry.slice(1, -1)];
|
||||
return entry.includes('"') ? [] : [entry];
|
||||
});
|
||||
};
|
||||
|
||||
const isRegularFile = (path: string): boolean => {
|
||||
try {
|
||||
return existsSync(path) && statSync(path).isFile();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
/** Resolve Windows bare command names against the captured child environment. */
|
||||
export const resolveWindowsProviderExecutable = (
|
||||
executable: string,
|
||||
environment: Readonly<NodeJS.ProcessEnv>,
|
||||
lookup: WindowsExecutableLookup = {},
|
||||
): string => {
|
||||
const cwd = lookup.cwd ?? process.cwd();
|
||||
const absolute = (...paths: string[]): string => win32.resolve(cwd, ...paths);
|
||||
if (
|
||||
win32.isAbsolute(executable) ||
|
||||
executable.includes("/") ||
|
||||
executable.includes("\\") ||
|
||||
win32.parse(executable).root !== ""
|
||||
)
|
||||
return absolute(executable);
|
||||
|
||||
const pathValue = environment.PATH ?? environment.Path ?? environment.path;
|
||||
if (pathValue === undefined) return executable;
|
||||
const pathEntries = splitWindowsPath(pathValue);
|
||||
const explicitExtension = win32.extname(executable).toLowerCase();
|
||||
if (explicitExtension !== "" && ![".com", ".exe"].includes(explicitExtension))
|
||||
return executable;
|
||||
const extensions =
|
||||
explicitExtension !== ""
|
||||
? [""]
|
||||
: (environment.PATHEXT ?? environment.PathExt ?? ".COM;.EXE")
|
||||
.split(";")
|
||||
.filter((extension) =>
|
||||
[".com", ".exe"].includes(extension.toLowerCase()),
|
||||
);
|
||||
|
||||
for (const entry of pathEntries) {
|
||||
if (entry.length === 0) continue;
|
||||
for (const extension of extensions) {
|
||||
const candidate = absolute(entry, `${executable}${extension}`);
|
||||
if ((lookup.isFile ?? isRegularFile)(candidate)) return candidate;
|
||||
}
|
||||
}
|
||||
return executable;
|
||||
};
|
||||
|
|
@ -0,0 +1,59 @@
|
|||
import type { ReverseEngineeringServiceRuntimeOptions } from "../application/reverse/ReverseEngineeringService.js";
|
||||
import type { ReverseEngineeringCommandPort } from "../application/reverse/ReverseEngineeringCommandPort.js";
|
||||
import { ReverseEngineeringService } from "../application/reverse/ReverseEngineeringService.js";
|
||||
import {
|
||||
objdumpCommand,
|
||||
OBJDUMP_PROVIDER_IDENTITY,
|
||||
} from "../objdump/ObjdumpCommand.js";
|
||||
import {
|
||||
rizinCommand,
|
||||
RIZIN_PROVIDER_IDENTITY,
|
||||
} from "../rizin/RizinCommand.js";
|
||||
import { resolveWindowsProviderExecutable } from "./resolveWindowsProviderExecutable.js";
|
||||
|
||||
export const createReverseEngineeringCommandPort = (
|
||||
environment: Readonly<NodeJS.ProcessEnv>,
|
||||
platform: NodeJS.Platform = process.platform,
|
||||
): ReverseEngineeringCommandPort => ({
|
||||
objdump: (input) => ({
|
||||
executable: providerExecutable(
|
||||
environment.REA_OBJDUMP_COMMAND ?? "objdump",
|
||||
environment,
|
||||
platform,
|
||||
),
|
||||
arguments: objdumpCommand(input),
|
||||
identity: OBJDUMP_PROVIDER_IDENTITY,
|
||||
environmentOverrides: { DEBUGINFOD_URLS: "" },
|
||||
}),
|
||||
rizin: (input) => ({
|
||||
executable: providerExecutable(
|
||||
environment.REA_RIZIN_COMMAND ?? "rizin",
|
||||
environment,
|
||||
platform,
|
||||
),
|
||||
arguments: rizinCommand(input),
|
||||
identity: RIZIN_PROVIDER_IDENTITY,
|
||||
}),
|
||||
});
|
||||
|
||||
const providerExecutable = (
|
||||
executable: string,
|
||||
environment: Readonly<NodeJS.ProcessEnv>,
|
||||
platform: NodeJS.Platform,
|
||||
): string =>
|
||||
platform === "win32"
|
||||
? resolveWindowsProviderExecutable(executable, environment)
|
||||
: executable;
|
||||
|
||||
/** Compose BYO reverse-engineering commands without acquiring tools at import time. */
|
||||
export const createReverseEngineeringService = (
|
||||
environment: Readonly<NodeJS.ProcessEnv> = process.env,
|
||||
options: ReverseEngineeringServiceRuntimeOptions = {},
|
||||
): ReverseEngineeringService => {
|
||||
const environmentSnapshot = { ...environment };
|
||||
return new ReverseEngineeringService({
|
||||
...options,
|
||||
environment: environmentSnapshot,
|
||||
commands: createReverseEngineeringCommandPort(environmentSnapshot),
|
||||
});
|
||||
};
|
||||
|
|
@ -0,0 +1,96 @@
|
|||
import { z } from "zod";
|
||||
import { jsonValueSchema } from "../domain/jsonValue.js";
|
||||
import type { ToolContract } from "./toolContractTypes.js";
|
||||
import { evidenceResultOf } from "./toolOutputSchemaPrimitives.js";
|
||||
import { toolContractMetadata } from "./toolEffects.js";
|
||||
|
||||
/** List live Cutter plugin bridge sessions. */
|
||||
export const cutterListInputSchema = z.strictObject({});
|
||||
|
||||
/** Execute a command in a selected live Cutter instance and observed document generation. */
|
||||
export const cutterCommandInputSchema = z.strictObject({
|
||||
session_id: z.string().uuid(),
|
||||
expected_generation: z.number().int().nonnegative(),
|
||||
command: z
|
||||
.string()
|
||||
.min(1)
|
||||
.refine(
|
||||
(value) => !/[\r\n]/u.test(value),
|
||||
"A single Rizin command line is required",
|
||||
),
|
||||
json: z.boolean().default(false),
|
||||
});
|
||||
|
||||
export const cutterSessionSchema = z.strictObject({
|
||||
session_id: z.string().uuid(),
|
||||
pid: z.number().int().positive(),
|
||||
document_generation: z.number().int().nonnegative(),
|
||||
current_file: z.string().nullable(),
|
||||
cutter_version: z.string().nullable(),
|
||||
identity_status: z.literal("partial"),
|
||||
});
|
||||
|
||||
export const cutterCommandResultSchema = z.strictObject({
|
||||
command: z.string(),
|
||||
output: jsonValueSchema.nullable(),
|
||||
execution_state: z.enum(["not_started", "complete", "failed", "unknown"]),
|
||||
error: z.string().nullable(),
|
||||
message: z.string().nullable(),
|
||||
output_truncated: z.boolean(),
|
||||
cutter_version: z.string().nullable(),
|
||||
current_file: z.string().nullable(),
|
||||
document_generation: z.number().int().nonnegative(),
|
||||
identity_status: z.literal("partial"),
|
||||
});
|
||||
|
||||
export const cutterDiscoverySchema = z.strictObject({
|
||||
sessions: z.array(cutterSessionSchema),
|
||||
bridge_directory: z.string(),
|
||||
discovery_status: z.enum([
|
||||
"sessions_found",
|
||||
"no_live_bridge_found",
|
||||
"bridge_directory_unavailable",
|
||||
"bridge_directory_insecure",
|
||||
"bridge_directory_capacity_exceeded",
|
||||
]),
|
||||
bridge_directory_security: z.enum([
|
||||
"private_verified",
|
||||
"not_private",
|
||||
"unverified_platform_acl",
|
||||
"not_checked",
|
||||
]),
|
||||
});
|
||||
|
||||
/** Live Cutter discovery and arbitrary Rizin command contracts. */
|
||||
export const CUTTER_TOOL_CONTRACTS = [
|
||||
{
|
||||
name: "list_cutter_sessions",
|
||||
...toolContractMetadata("list_cutter_sessions"),
|
||||
kind: "native-provider",
|
||||
description:
|
||||
"List live REA bridge sessions hosted by upstream Cutter instances, including observed document identity and bridge-directory security status. Discovery admits at most 256 descriptor candidates (16 MiB at the descriptor byte limit), reads and probes at most eight concurrently, and reports bridge_directory_capacity_exceeded rather than claiming no sessions when that budget is exceeded.",
|
||||
inputSchema: cutterListInputSchema,
|
||||
outputSchema: cutterDiscoverySchema,
|
||||
examples: [{ title: "List open Cutter instances", input: {} }],
|
||||
},
|
||||
{
|
||||
name: "cutter_command",
|
||||
...toolContractMetadata("cutter_command"),
|
||||
kind: "native-provider",
|
||||
description:
|
||||
"Run one arbitrary Rizin command in a selected live Cutter session. Commands may edit and persist analysis state or execute plugin-provided effects; completion uncertainty is returned as Evidence and must not be retried automatically. In a shared MCP server, this admitted command closes any active binary-analysis session; after its effects have settled, call open_binary before further analysis. Close an active Ghidra session explicitly first.",
|
||||
inputSchema: cutterCommandInputSchema,
|
||||
outputSchema: evidenceResultOf(cutterCommandResultSchema),
|
||||
examples: [
|
||||
{
|
||||
title: "Inspect active binary metadata",
|
||||
input: {
|
||||
session_id: "00000000-0000-4000-8000-000000000001",
|
||||
expected_generation: 0,
|
||||
command: "ij",
|
||||
json: true,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
] as const satisfies readonly ToolContract[];
|
||||
|
|
@ -0,0 +1,113 @@
|
|||
import { z } from "zod";
|
||||
|
||||
import type { ToolContract } from "./toolContractTypes.js";
|
||||
import { evidenceResultOf } from "./toolOutputSchemaPrimitives.js";
|
||||
import { toolContractMetadata } from "./toolEffects.js";
|
||||
|
||||
const sessionId = z.string().uuid();
|
||||
const gdbCommandResult = z.strictObject({
|
||||
command: z.string(),
|
||||
mi: z.string(),
|
||||
records: z.array(z.string()),
|
||||
console: z.string(),
|
||||
target: z.string(),
|
||||
log: z.string(),
|
||||
process_exit_observed: z.boolean().optional(),
|
||||
process_exit_code: z.number().int().nullable().optional(),
|
||||
process_exit_signal: z.string().nullable().optional(),
|
||||
output_truncated: z.boolean(),
|
||||
output_invalid_utf8: z.literal(true).optional(),
|
||||
completion_status: z.enum(["complete", "unknown"]),
|
||||
});
|
||||
|
||||
/** Persistent GDB/MI session contracts hosted by the existing REA MCP server. */
|
||||
export const GDB_TOOL_CONTRACTS = [
|
||||
{
|
||||
name: "start_gdb_session",
|
||||
...toolContractMetadata("start_gdb_session"),
|
||||
kind: "native-provider",
|
||||
description:
|
||||
"Start a BYO GDB process in MI3 mode when available, suppress startup files, and disable auto-loading. Persistent GDB sessions are available only on Linux, where REA can verify live inferior output descriptors through /proc before detaching; macOS descriptor verification is not implemented, and Windows Job Object cleanup can terminate debugger inferiors. Optionally set inferior_tty to a caller-selected terminal for future inferior runs; REA verifies the setting through GDB/MI before the session becomes ready. The selected terminal must remain available after GDB exits. A live inferior can be detached during close only while this durable output sink remains verified. The session persists in this REA MCP server process and receives an explicit session ID.",
|
||||
inputSchema: z.strictObject({
|
||||
inferior_tty: z
|
||||
.string()
|
||||
.min(1)
|
||||
.optional()
|
||||
.describe(
|
||||
"Caller-selected terminal path for future inferior runs. GDB must report the exact configured path before the session is ready.",
|
||||
),
|
||||
}),
|
||||
outputSchema: z.strictObject({
|
||||
session_id: sessionId,
|
||||
mi_version: z.enum(["mi3", "mi2"]),
|
||||
}),
|
||||
examples: [{ title: "Start GDB", input: {} }],
|
||||
},
|
||||
{
|
||||
name: "gdb_console",
|
||||
...toolContractMetadata("gdb_console"),
|
||||
kind: "native-provider",
|
||||
description:
|
||||
"Run one unrestricted GDB CLI command through MI interpreter-exec console and return raw MI, console, target, and log records inline with Evidence. A command timeout preserves collected records with completion_status unknown and stops the owned debugger session. Commands can execute shell, alter target state, access files, and use network. Closing the debugger never implies target termination. In a shared MCP server, this admitted command closes any active binary-analysis session; after its effects have settled, call open_binary before further analysis. Close an active Ghidra session explicitly first.",
|
||||
inputSchema: z.strictObject({
|
||||
session_id: sessionId,
|
||||
command: z.string().min(1),
|
||||
}),
|
||||
outputSchema: evidenceResultOf(gdbCommandResult),
|
||||
examples: [
|
||||
{
|
||||
title: "Inspect loaded symbols",
|
||||
input: {
|
||||
session_id: "00000000-0000-4000-8000-000000000001",
|
||||
command: "info functions",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "gdb_session_status",
|
||||
...toolContractMetadata("gdb_session_status"),
|
||||
kind: "native-provider",
|
||||
description:
|
||||
"Return the observed lifecycle state and bounded recent MI records and process diagnostics for one persistent REA GDB session; truncation and invalid-UTF-8 fields report discarded or lossy data.",
|
||||
inputSchema: z.strictObject({ session_id: sessionId }),
|
||||
outputSchema: z.strictObject({
|
||||
session_id: sessionId,
|
||||
mi_version: z.enum(["mi3", "mi2"]),
|
||||
state: z.enum(["ready", "closed"]),
|
||||
recent_mi_records: z.array(z.string()),
|
||||
recent_mi_records_truncated: z.boolean(),
|
||||
mi_invalid_utf8: z.boolean(),
|
||||
stderr: z.string(),
|
||||
stderr_observed_bytes: z.number().int().nonnegative(),
|
||||
stderr_truncated: z.boolean(),
|
||||
stderr_invalid_utf8: z.boolean(),
|
||||
diagnostics_truncated: z.boolean(),
|
||||
inferior_tty: z.string().nullable(),
|
||||
inferior_tty_verified: z.boolean(),
|
||||
}),
|
||||
examples: [
|
||||
{
|
||||
title: "Check GDB session",
|
||||
input: { session_id: "00000000-0000-4000-8000-000000000001" },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "close_gdb_session",
|
||||
...toolContractMetadata("close_gdb_session"),
|
||||
kind: "native-provider",
|
||||
description:
|
||||
"Close the owned GDB process after verifying the inferior state. When one live thread group has a verified durable output TTY, REA automatically detaches it, which may resume target execution; otherwise close reports that a disposition is required. REA never kills attached or remote targets as part of debugger cleanup. In a shared MCP server, this admitted close retires any active binary-analysis session; after target effects have settled, call open_binary before further analysis. Close an active Ghidra session explicitly first.",
|
||||
inputSchema: z.strictObject({ session_id: sessionId }),
|
||||
outputSchema: z.strictObject({
|
||||
target_state: z.enum(["none_observed", "unknown"]),
|
||||
}),
|
||||
examples: [
|
||||
{
|
||||
title: "Close GDB session",
|
||||
input: { session_id: "00000000-0000-4000-8000-000000000001" },
|
||||
},
|
||||
],
|
||||
},
|
||||
] as const satisfies readonly ToolContract[];
|
||||
|
|
@ -0,0 +1,61 @@
|
|||
import { z } from "zod";
|
||||
|
||||
import {
|
||||
objdumpInputSchema,
|
||||
rizinInputSchema,
|
||||
} from "../domain/reverseEngineering.js";
|
||||
import type { ToolContract } from "./toolContractTypes.js";
|
||||
import { evidenceResultOf } from "./toolOutputSchemaPrimitives.js";
|
||||
import { toolContractMetadata } from "./toolEffects.js";
|
||||
|
||||
const commandObservationSchema = z.strictObject({
|
||||
stdout: z.string(),
|
||||
stderr: z.string(),
|
||||
exit_code: z.union([z.string(), z.number(), z.null()]),
|
||||
signal: z.string().nullable(),
|
||||
output_truncated: z.boolean(),
|
||||
completion_status: z.enum(["complete", "unknown"]),
|
||||
artifact_sha256_after: z
|
||||
.string()
|
||||
.regex(/^[a-f0-9]{64}$/u)
|
||||
.nullable(),
|
||||
artifact_changed: z.boolean().nullable(),
|
||||
});
|
||||
|
||||
/** Caller-selected GNU objdump and Rizin observations. */
|
||||
export const REVERSE_ENGINEERING_TOOL_CONTRACTS = [
|
||||
{
|
||||
name: "inspect_with_objdump",
|
||||
...toolContractMetadata("inspect_with_objdump"),
|
||||
kind: "native-provider",
|
||||
description:
|
||||
"Run caller-selected GNU objdump inspection against a local artifact, or request operation=architectures without a path to list BFD targets advertised by the installed binary. Preserves decoded stdout and stderr inline with SHA-256 Evidence for artifact inspections, up to a shared 16 MiB combined limit; invalid UTF-8 bytes are represented by replacement characters and flagged in Evidence limitations. output_truncated reports output beyond that limit and completion_status is unknown if buffer enforcement terminated the process. The raw output is authoritative and REA does not claim a stable normalized format. DWARF does not follow local debug links by default. Set follow_debug_links to true to follow them; identities of external debug files are not bound to Evidence. REA clears DEBUGINFOD_URLS for objdump so DWARF processing does not make debuginfod network requests. Architecture output is subjectless and does not establish support for any artifact.",
|
||||
inputSchema: objdumpInputSchema,
|
||||
outputSchema: evidenceResultOf(commandObservationSchema),
|
||||
examples: [
|
||||
{
|
||||
title: "Disassemble code sections",
|
||||
input: { path: "/tmp/program", operation: "disassemble" },
|
||||
},
|
||||
{
|
||||
title: "List advertised BFD targets",
|
||||
input: { operation: "architectures" },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "execute_rizin_command",
|
||||
...toolContractMetadata("execute_rizin_command"),
|
||||
kind: "native-provider",
|
||||
description:
|
||||
"Execute one caller-selected Rizin command against a local artifact using the Rizin CLI profile that suppresses user settings and scripts while retaining plugins. Preserves decoded stdout and stderr inline with SHA-256 Evidence, up to a shared 16 MiB combined limit; invalid UTF-8 bytes are represented by replacement characters and flagged in Evidence limitations. output_truncated reports output beyond that limit and completion_status is unknown if buffer enforcement terminated the process. Rizin commands and retained plugins can execute foreign code, mutate files, access the network, and read files outside the selected artifact; identities and contents of those external inputs are not bound to Evidence. Command effects depend on the Rizin command and loaded plugins; JSON is command-specific and raw output remains authoritative. In a shared MCP server, this admitted command closes any active binary-analysis session; after its effects have settled, call open_binary before further analysis. Close an active Ghidra session explicitly first.",
|
||||
inputSchema: rizinInputSchema,
|
||||
outputSchema: evidenceResultOf(commandObservationSchema),
|
||||
examples: [
|
||||
{
|
||||
title: "Inspect binary information",
|
||||
input: { path: "/tmp/program", command: "iI" },
|
||||
},
|
||||
],
|
||||
},
|
||||
] as const satisfies readonly ToolContract[];
|
||||
|
|
@ -0,0 +1,107 @@
|
|||
import { z } from "zod";
|
||||
|
||||
import type { ToolContract } from "./toolContractTypes.js";
|
||||
import { evidenceResultOf } from "./toolOutputSchemaPrimitives.js";
|
||||
import { toolContractMetadata } from "./toolEffects.js";
|
||||
|
||||
const sessionId = z.string().uuid();
|
||||
const commandResult = z.strictObject({
|
||||
command: z.string(),
|
||||
output: z.string(),
|
||||
output_scope: z.literal("command_and_interleaved_session_output"),
|
||||
output_truncated: z.boolean(),
|
||||
output_invalid_utf8: z.literal(true).optional(),
|
||||
completion_status: z.enum(["complete", "unknown"]),
|
||||
backend: z.string().nullable(),
|
||||
});
|
||||
|
||||
/** Persistent Rizin debugger session contracts. */
|
||||
export const RIZIN_DEBUG_TOOL_CONTRACTS = [
|
||||
{
|
||||
name: "start_rizin_debug_session",
|
||||
...toolContractMetadata("start_rizin_debug_session"),
|
||||
kind: "native-provider",
|
||||
description:
|
||||
"Start a persistent Rizin debugger session using `-N` to suppress user settings and scripts while retaining plugins, `-0` for NUL framing, and `-d` with an optional caller-selected IO debugger backend. Requires a caller-selected Linux TTY for inferior stdout and stderr; the target may write directly to that terminal while running and after detach. REA verifies both destinations and `dbg.exitkills=false` before detaching. Backend and target capabilities depend on the installed build. The session lives in this REA MCP process. Persistent sessions require Linux `/proc` descriptor readback. In a shared MCP server, starting this session retires any active binary-analysis session; after target effects have settled, call open_binary before further analysis. Close an active Ghidra session explicitly first.",
|
||||
inputSchema: z.strictObject({
|
||||
path: z.string().min(1),
|
||||
inferior_tty: z.string().min(1),
|
||||
backend: z.string().min(1).optional(),
|
||||
}),
|
||||
outputSchema: z.strictObject({
|
||||
session_id: sessionId,
|
||||
backend: z.string().nullable(),
|
||||
}),
|
||||
examples: [
|
||||
{
|
||||
title: "Start Rizin debugger",
|
||||
input: { path: "/tmp/program", inferior_tty: "/dev/pts/7" },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "rizin_debug_command",
|
||||
...toolContractMetadata("rizin_debug_command"),
|
||||
kind: "native-provider",
|
||||
description:
|
||||
"Execute one unrestricted command in a persistent Rizin debugger session. Returns raw output inline with Evidence, including backend output interleaved before the completion marker. If the command exceeds the output or frame limit, `output_truncated` is true and `completion_status` is `unknown`; retained partial output is returned and the owned debugger session is stopped. Unsolicited frames already queued before the command make the session unavailable because correlation is ambiguous. Commands can access local files, shell, network, plugins, and target state; effects depend on the active backend and target. In a shared MCP server, this admitted command closes any active binary-analysis session; after its effects have settled, call open_binary before further analysis. Close an active Ghidra session explicitly first.",
|
||||
inputSchema: z.strictObject({
|
||||
session_id: sessionId,
|
||||
command: z
|
||||
.string()
|
||||
.min(1)
|
||||
.refine(
|
||||
(value) => !/[\r\n]/u.test(value),
|
||||
"A single command line is required",
|
||||
),
|
||||
}),
|
||||
outputSchema: evidenceResultOf(commandResult),
|
||||
examples: [
|
||||
{
|
||||
title: "Inspect debugger state",
|
||||
input: {
|
||||
session_id: "00000000-0000-4000-8000-000000000001",
|
||||
command: "dr",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "rizin_debug_session_status",
|
||||
...toolContractMetadata("rizin_debug_session_status"),
|
||||
kind: "native-provider",
|
||||
description:
|
||||
"Return observed lifecycle state and bounded recent NUL-framed output and process diagnostics for one Rizin debugger session; truncation fields report discarded data, and output_invalid_utf8 reports whether invalid UTF-8 has been observed in session frame bytes; recent output may contain replacement characters.",
|
||||
inputSchema: z.strictObject({ session_id: sessionId }),
|
||||
outputSchema: z.strictObject({
|
||||
session_id: sessionId,
|
||||
state: z.enum(["ready", "closed"]),
|
||||
inferior_tty: z.string(),
|
||||
recent_output: z.array(z.string()),
|
||||
recent_output_truncated: z.boolean(),
|
||||
diagnostics_truncated: z.boolean(),
|
||||
output_invalid_utf8: z.boolean(),
|
||||
}),
|
||||
examples: [
|
||||
{
|
||||
title: "Check Rizin session",
|
||||
input: { session_id: "00000000-0000-4000-8000-000000000001" },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "close_rizin_debug_session",
|
||||
...toolContractMetadata("close_rizin_debug_session"),
|
||||
kind: "native-provider",
|
||||
description:
|
||||
"Close the REA-owned Rizin debugger only after verifying no inferior remains or detaching one with a verified caller-selected TTY output sink. A detached target may continue running and write to that TTY; target state is otherwise reported as unknown. REA never signals an inferior based on PID or path. In a shared MCP server, this admitted close retires any active binary-analysis session; after target effects have settled, call open_binary before further analysis. Close an active Ghidra session explicitly first.",
|
||||
inputSchema: z.strictObject({ session_id: sessionId }),
|
||||
outputSchema: z.strictObject({ target_state: z.literal("unknown") }),
|
||||
examples: [
|
||||
{
|
||||
title: "Close Rizin debugger",
|
||||
input: { session_id: "00000000-0000-4000-8000-000000000001" },
|
||||
},
|
||||
],
|
||||
},
|
||||
] as const satisfies readonly ToolContract[];
|
||||
|
|
@ -82,6 +82,11 @@ export const TOOL_EXAMPLE_OVERRIDES: Readonly<
|
|||
left_bundle_path: "/tmp/left-evidence.json",
|
||||
right_bundle_path: "/tmp/right-evidence.json",
|
||||
},
|
||||
inspect_with_objdump: {
|
||||
path: "/tmp/program",
|
||||
operation: "disassemble",
|
||||
},
|
||||
execute_rizin_command: { path: "/tmp/program", command: "iI" },
|
||||
list_frida_devices: {},
|
||||
list_frida_processes: { device_id: "local" },
|
||||
start_frida_session: { mode: "attach", device_id: "local", pid: 1234 },
|
||||
|
|
|
|||
|
|
@ -29,6 +29,10 @@ import { JAVASCRIPT_RECOVERY_TOOL_CONTRACTS } from "./javascript/javascriptRecov
|
|||
import { OFFICIAL_TOOL_CONTRACTS } from "./officialToolContracts.js";
|
||||
import { ENHANCED_TOOL_CONTRACTS } from "./enhancedToolContracts.js";
|
||||
import { SESSION_TOOL_CONTRACTS } from "./sessionToolContracts.js";
|
||||
import { CUTTER_TOOL_CONTRACTS } from "./cutterToolContracts.js";
|
||||
import { REVERSE_ENGINEERING_TOOL_CONTRACTS } from "./reverseEngineeringToolContracts.js";
|
||||
import { GDB_TOOL_CONTRACTS } from "./gdbToolContracts.js";
|
||||
import { RIZIN_DEBUG_TOOL_CONTRACTS } from "./rizinDebugToolContracts.js";
|
||||
import { FRIDA_TOOL_CONTRACTS } from "./fridaToolContracts.js";
|
||||
|
||||
/** Complete ordered public inventory used by registration and verification. */
|
||||
|
|
@ -64,6 +68,10 @@ export const TOOL_CONTRACTS = [
|
|||
...WEB_NETWORK_CAPTURE_TOOL_CONTRACTS,
|
||||
...JAVASCRIPT_RECOVERY_TOOL_CONTRACTS,
|
||||
...SESSION_TOOL_CONTRACTS,
|
||||
...CUTTER_TOOL_CONTRACTS,
|
||||
...REVERSE_ENGINEERING_TOOL_CONTRACTS,
|
||||
...GDB_TOOL_CONTRACTS,
|
||||
...RIZIN_DEBUG_TOOL_CONTRACTS,
|
||||
...FRIDA_TOOL_CONTRACTS,
|
||||
] as const;
|
||||
|
||||
|
|
|
|||
|
|
@ -13,6 +13,10 @@ const EXECUTION_WORDING =
|
|||
/\b(?:run|runs|running|launch|launches|launching|execute|executes|executing|execution|start|starts|install|installs|uninstall|uninstalls)\b/iu;
|
||||
|
||||
const EXECUTION_WORDING_EXCEPTIONS: Readonly<Record<string, string>> = {
|
||||
inspect_with_objdump:
|
||||
"runs a caller-selected inspection tool, not target code",
|
||||
start_gdb_session:
|
||||
"starts a debugger session; execution is attributed to gdb_console",
|
||||
start_frida_session:
|
||||
"spawned targets remain suspended until resume_frida_session",
|
||||
inspect_native_load_image: "states that it does not execute the target",
|
||||
|
|
@ -129,14 +133,21 @@ describe("tool effects", () => {
|
|||
"capture_electron_scenario",
|
||||
"capture_native_ui_scenario",
|
||||
"capture_process_scenario",
|
||||
"close_gdb_session",
|
||||
"close_rizin_debug_session",
|
||||
"cutter_command",
|
||||
"execute_rizin_command",
|
||||
"gdb_console",
|
||||
"install_adb_package",
|
||||
"instrument_with_frida",
|
||||
"load_frida_script",
|
||||
"observe_native_calls",
|
||||
"push_adb_file",
|
||||
"resume_frida_session",
|
||||
"rizin_debug_command",
|
||||
"start_adb_activity",
|
||||
"start_adb_app",
|
||||
"start_rizin_debug_session",
|
||||
"stop_adb_app",
|
||||
"uninstall_adb_package",
|
||||
]);
|
||||
|
|
@ -169,6 +180,17 @@ describe("tool effects", () => {
|
|||
});
|
||||
});
|
||||
|
||||
it("marks automatic GDB detach as destructive target execution", () => {
|
||||
const effects = TOOL_EFFECTS["close_gdb_session"];
|
||||
if (effects === undefined)
|
||||
throw new Error("close_gdb_session is unaudited");
|
||||
expect(effects).toMatchObject({
|
||||
mutatesTarget: true,
|
||||
executesForeignCode: true,
|
||||
});
|
||||
expect(annotationsFromEffects(effects).destructiveHint).toBe(true);
|
||||
});
|
||||
|
||||
it("publishes the advertised annotations derived from canonical effects", () => {
|
||||
for (const contract of TOOL_CONTRACTS)
|
||||
expect(contract.annotations, contract.name).toEqual(
|
||||
|
|
|
|||
|
|
@ -53,6 +53,78 @@ const sessionEvidence = effects({ mutatesSession: true });
|
|||
|
||||
/** Explicit effect audit for every public tool. */
|
||||
export const TOOL_EFFECTS: Readonly<Record<string, ToolEffects>> = {
|
||||
list_cutter_sessions: effects({ accessesNetwork: true }),
|
||||
cutter_command: effects({
|
||||
mutatesTarget: true,
|
||||
executesForeignCode: true,
|
||||
mutatesSession: true,
|
||||
writesFilesystem: true,
|
||||
launchesProcess: true,
|
||||
accessesNetwork: true,
|
||||
changesUiState: true,
|
||||
idempotent: false,
|
||||
}),
|
||||
inspect_with_objdump: effects({
|
||||
mutatesSession: true,
|
||||
launchesProcess: true,
|
||||
}),
|
||||
start_gdb_session: effects({
|
||||
launchesProcess: true,
|
||||
mutatesSession: true,
|
||||
idempotent: false,
|
||||
}),
|
||||
gdb_console: effects({
|
||||
mutatesTarget: true,
|
||||
mutatesSession: true,
|
||||
writesFilesystem: true,
|
||||
launchesProcess: true,
|
||||
accessesNetwork: true,
|
||||
executesForeignCode: true,
|
||||
idempotent: false,
|
||||
}),
|
||||
gdb_session_status: effects(),
|
||||
start_rizin_debug_session: effects({
|
||||
mutatesTarget: true,
|
||||
mutatesSession: true,
|
||||
launchesProcess: true,
|
||||
executesForeignCode: true,
|
||||
idempotent: false,
|
||||
}),
|
||||
rizin_debug_command: effects({
|
||||
mutatesTarget: true,
|
||||
mutatesSession: true,
|
||||
executesForeignCode: true,
|
||||
writesFilesystem: true,
|
||||
launchesProcess: true,
|
||||
accessesNetwork: true,
|
||||
changesUiState: true,
|
||||
mayDiscardData: true,
|
||||
idempotent: false,
|
||||
}),
|
||||
rizin_debug_session_status: effects({ mutatesSession: true }),
|
||||
close_rizin_debug_session: effects({
|
||||
mutatesSession: true,
|
||||
launchesProcess: true,
|
||||
mutatesTarget: true,
|
||||
executesForeignCode: true,
|
||||
}),
|
||||
close_gdb_session: effects({
|
||||
launchesProcess: true,
|
||||
mutatesSession: true,
|
||||
mutatesTarget: true,
|
||||
executesForeignCode: true,
|
||||
idempotent: false,
|
||||
}),
|
||||
execute_rizin_command: effects({
|
||||
mutatesTarget: true,
|
||||
mutatesSession: true,
|
||||
writesFilesystem: true,
|
||||
launchesProcess: true,
|
||||
accessesNetwork: true,
|
||||
mayDiscardData: true,
|
||||
executesForeignCode: true,
|
||||
idempotent: false,
|
||||
}),
|
||||
inspect_go_binary: evidence,
|
||||
inspect_rust_binary: evidence,
|
||||
inspect_sqlite_database: effects({
|
||||
|
|
|
|||
|
|
@ -0,0 +1,891 @@
|
|||
import { randomUUID } from "node:crypto";
|
||||
import { constants as fsConstants } from "node:fs";
|
||||
import { createHmac, randomBytes, timingSafeEqual } from "node:crypto";
|
||||
import { createConnection } from "node:net";
|
||||
import { lstat, open, opendir } from "node:fs/promises";
|
||||
import { homedir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { z } from "zod";
|
||||
|
||||
import {
|
||||
AnalysisCapabilityUnavailableError,
|
||||
AnalysisOutputError,
|
||||
AnalysisResourceConstraintError,
|
||||
} from "../domain/analysisErrorCore.js";
|
||||
import type { AnalysisError } from "../domain/analysisErrorBase.js";
|
||||
import { err, ok, type Result } from "../domain/result.js";
|
||||
import { requireWindowsNativeAuthority } from "../windows/WindowsNativeLoader.js";
|
||||
import { readBoundedFileBytes } from "../process/BoundedFileBytes.js";
|
||||
|
||||
const descriptorSchema = z.object({
|
||||
session_id: z.string().uuid(),
|
||||
pid: z.number().int().positive(),
|
||||
host: z.literal("127.0.0.1"),
|
||||
port: z.number().int().min(1).max(65535),
|
||||
token: z.string().min(20),
|
||||
document_generation: z.number().int().nonnegative(),
|
||||
current_file: z.string().nullable(),
|
||||
cutter_version: z.string().nullable(),
|
||||
identity_status: z.literal("partial"),
|
||||
});
|
||||
|
||||
export const CUTTER_PROVIDER_IDENTITY = {
|
||||
id: "cutter.python-plugin",
|
||||
name: "Cutter Python plugin",
|
||||
version: null,
|
||||
} as const;
|
||||
|
||||
const responseSchema = z.object({
|
||||
ok: z.boolean(),
|
||||
session_id: z.string().uuid().optional(),
|
||||
output: z.unknown().optional(),
|
||||
current_file: z.string().nullable().optional(),
|
||||
cutter_version: z.string().nullable().optional(),
|
||||
document_generation: z.number().int().nonnegative().optional(),
|
||||
identity_status: z.literal("partial").optional(),
|
||||
error: z.string().optional(),
|
||||
message: z.string().optional(),
|
||||
execution_state: z
|
||||
.enum(["not_started", "running", "complete", "failed", "unknown"])
|
||||
.optional(),
|
||||
output_truncated: z.boolean().optional(),
|
||||
completed_commands: z
|
||||
.array(
|
||||
z.object({
|
||||
request_id: z.string().uuid(),
|
||||
execution_state: z.enum(["not_started", "complete", "failed"]),
|
||||
}),
|
||||
)
|
||||
.max(64)
|
||||
.optional(),
|
||||
completed_connection: z
|
||||
.string()
|
||||
.regex(/^[0-9a-f]{16}$/)
|
||||
.optional(),
|
||||
});
|
||||
const acknowledgementSchema = z.object({
|
||||
ok: z.literal(true),
|
||||
acknowledged: z.boolean(),
|
||||
});
|
||||
const MAX_DESCRIPTOR_BYTES = 64 * 1024;
|
||||
const MAX_FRAME_BYTES = 16 * 1024 * 1024;
|
||||
const FRAME_PAGE_BYTES = 64 * 1024;
|
||||
// Bound admitted descriptor input bytes and concurrent file/socket handles.
|
||||
const MAX_DESCRIPTOR_CANDIDATES = MAX_FRAME_BYTES / MAX_DESCRIPTOR_BYTES;
|
||||
const DISCOVERY_CONCURRENCY = 8;
|
||||
type AuthenticatedRequestBytes = string & {
|
||||
readonly __authenticatedRequestBytes: unique symbol;
|
||||
};
|
||||
|
||||
const serializeRequest = (
|
||||
descriptor: z.infer<typeof descriptorSchema>,
|
||||
request: Readonly<Record<string, unknown>>,
|
||||
): AuthenticatedRequestBytes => {
|
||||
const requestBytes = JSON.stringify({
|
||||
...request,
|
||||
session_id: descriptor.session_id,
|
||||
});
|
||||
let frameBytes =
|
||||
Buffer.byteLength(JSON.stringify({ request: "", proof: "0".repeat(64) })) +
|
||||
Buffer.byteLength(requestBytes);
|
||||
for (const character of requestBytes)
|
||||
if (character === '"' || character === "\\") frameBytes += 1;
|
||||
if (frameBytes > MAX_FRAME_BYTES)
|
||||
throw new AnalysisResourceConstraintError(
|
||||
"cutter_command",
|
||||
"transport",
|
||||
"Cutter request exceeds its authenticated frame limit; no command was sent.",
|
||||
{ frame_bytes: frameBytes, max_frame_bytes: MAX_FRAME_BYTES },
|
||||
);
|
||||
return requestBytes as AuthenticatedRequestBytes;
|
||||
};
|
||||
|
||||
class CutterBridgeResponseFrames {
|
||||
#parts: Buffer[] = [];
|
||||
#page = Buffer.allocUnsafe(FRAME_PAGE_BYTES);
|
||||
#pageLength = 0;
|
||||
#contentBytes = 0;
|
||||
#framesRead = 0;
|
||||
|
||||
push(chunk: Buffer, onFrame: (line: Buffer) => boolean): void {
|
||||
let offset = 0;
|
||||
while (offset < chunk.length) {
|
||||
const delimiter = chunk.indexOf(0x0a, offset);
|
||||
const end = delimiter < 0 ? chunk.length : delimiter;
|
||||
const segment = chunk.subarray(offset, end);
|
||||
if (
|
||||
this.#contentBytes + segment.length + (delimiter < 0 ? 1 : 0) >
|
||||
MAX_FRAME_BYTES
|
||||
)
|
||||
throw new Error(
|
||||
"Cutter bridge response exceeded its framed transport limit",
|
||||
);
|
||||
this.#append(segment);
|
||||
if (delimiter < 0) return;
|
||||
if (++this.#contentBytes > MAX_FRAME_BYTES)
|
||||
throw new Error(
|
||||
"Cutter bridge response exceeded its framed transport limit",
|
||||
);
|
||||
if (this.#pageLength > 0)
|
||||
this.#parts.push(this.#page.subarray(0, this.#pageLength));
|
||||
const line = Buffer.concat(this.#parts, this.#contentBytes - 1);
|
||||
this.#reset();
|
||||
if (++this.#framesRead > 2)
|
||||
throw new Error("Cutter bridge sent more than two protocol frames");
|
||||
offset = delimiter + 1;
|
||||
if (!onFrame(line)) return;
|
||||
}
|
||||
}
|
||||
|
||||
#append(segment: Buffer): void {
|
||||
let copied = 0;
|
||||
while (copied < segment.length) {
|
||||
const length = Math.min(
|
||||
segment.length - copied,
|
||||
FRAME_PAGE_BYTES - this.#pageLength,
|
||||
);
|
||||
segment.copy(this.#page, this.#pageLength, copied, copied + length);
|
||||
this.#pageLength += length;
|
||||
this.#contentBytes += length;
|
||||
copied += length;
|
||||
if (this.#pageLength === FRAME_PAGE_BYTES) {
|
||||
this.#parts.push(this.#page);
|
||||
this.#page = Buffer.allocUnsafe(FRAME_PAGE_BYTES);
|
||||
this.#pageLength = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#reset(): void {
|
||||
this.#parts = [];
|
||||
this.#page = Buffer.allocUnsafe(FRAME_PAGE_BYTES);
|
||||
this.#pageLength = 0;
|
||||
this.#contentBytes = 0;
|
||||
}
|
||||
}
|
||||
|
||||
const mapDiscovery = async <Input, Output>(
|
||||
inputs: readonly Input[],
|
||||
operation: (input: Input) => Promise<Output>,
|
||||
): Promise<Output[]> => {
|
||||
const results = new Array<Output>(inputs.length);
|
||||
let next = 0;
|
||||
await Promise.all(
|
||||
Array.from(
|
||||
{ length: Math.min(inputs.length, DISCOVERY_CONCURRENCY) },
|
||||
async () => {
|
||||
while (next < inputs.length) {
|
||||
const index = next++;
|
||||
results[index] = await operation(inputs[index]!);
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
return results;
|
||||
};
|
||||
export type CutterBridgeExecutionState =
|
||||
| "not_started"
|
||||
| "complete"
|
||||
| "failed"
|
||||
| "unknown";
|
||||
export interface CutterBridgeExecution {
|
||||
readonly requestId: string;
|
||||
readonly output: unknown;
|
||||
readonly currentFile: string | null;
|
||||
readonly documentGeneration: number;
|
||||
readonly identityStatus: "partial";
|
||||
readonly cutterVersion: string | null;
|
||||
readonly executionState: CutterBridgeExecutionState;
|
||||
readonly error: string | null;
|
||||
readonly message: string | null;
|
||||
readonly outputTruncated: boolean;
|
||||
readonly completionToken?: string;
|
||||
}
|
||||
|
||||
class CutterBridgeRequestFailure extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
readonly requestSent: boolean,
|
||||
) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
|
||||
export interface WindowsCutterDescriptorReader {
|
||||
verifyDirectory(directory: string): boolean;
|
||||
readDescriptor(directory: string, name: string, maxBytes: number): Buffer;
|
||||
}
|
||||
interface CutterBridgeClientOptions {
|
||||
readonly platform?: NodeJS.Platform;
|
||||
readonly windowsPrivateReader?: WindowsCutterDescriptorReader;
|
||||
}
|
||||
|
||||
export type CutterBridgeSession = Omit<
|
||||
z.infer<typeof descriptorSchema>,
|
||||
"token" | "host" | "port"
|
||||
>;
|
||||
export interface CutterBridgeDiscovery {
|
||||
readonly sessions: readonly CutterBridgeSession[];
|
||||
readonly bridge_directory: string;
|
||||
readonly discovery_status:
|
||||
| "sessions_found"
|
||||
| "no_live_bridge_found"
|
||||
| "bridge_directory_unavailable"
|
||||
| "bridge_directory_insecure"
|
||||
| "bridge_directory_capacity_exceeded";
|
||||
readonly bridge_directory_security:
|
||||
| "private_verified"
|
||||
| "not_private"
|
||||
| "unverified_platform_acl"
|
||||
| "not_checked";
|
||||
}
|
||||
|
||||
/** Discovers and communicates with REA Python bridge plugins in live Cutter processes. */
|
||||
export class CutterBridgeClient {
|
||||
readonly #directory: string;
|
||||
readonly #platform: NodeJS.Platform;
|
||||
readonly #windowsPrivateReader: WindowsCutterDescriptorReader | undefined;
|
||||
readonly #pendingAcknowledgements = new Map<string, Set<string>>();
|
||||
|
||||
constructor(
|
||||
environment: Readonly<NodeJS.ProcessEnv> = process.env,
|
||||
options: CutterBridgeClientOptions = {},
|
||||
) {
|
||||
const configured = environment.REA_CUTTER_BRIDGE_DIR;
|
||||
const localAppData = environment.LOCALAPPDATA;
|
||||
this.#platform = options.platform ?? process.platform;
|
||||
this.#windowsPrivateReader =
|
||||
options.windowsPrivateReader ??
|
||||
(this.#platform === "win32"
|
||||
? {
|
||||
verifyDirectory: (directory) =>
|
||||
z
|
||||
.object({ privateDacl: z.literal(true) })
|
||||
.safeParse(
|
||||
requireWindowsNativeAuthority().call(
|
||||
"cutter_bridge_verify_directory",
|
||||
[directory],
|
||||
),
|
||||
).success,
|
||||
readDescriptor: (directory, name, maxBytes) =>
|
||||
z
|
||||
.instanceof(Buffer)
|
||||
.parse(
|
||||
requireWindowsNativeAuthority().call(
|
||||
"cutter_bridge_read_descriptor",
|
||||
[directory, name, maxBytes],
|
||||
),
|
||||
),
|
||||
}
|
||||
: undefined);
|
||||
this.#directory =
|
||||
configured ??
|
||||
(this.#platform === "win32" && localAppData
|
||||
? join(localAppData, "REA", "CutterBridge")
|
||||
: join(homedir(), ".cache", "rea", "cutter-bridge"));
|
||||
}
|
||||
|
||||
async listSessions(): Promise<CutterBridgeDiscovery> {
|
||||
const loaded = await this.#readDescriptors();
|
||||
if (loaded.error !== undefined)
|
||||
return {
|
||||
sessions: [],
|
||||
bridge_directory: this.#directory,
|
||||
discovery_status: loaded.error,
|
||||
bridge_directory_security: loaded.security,
|
||||
};
|
||||
const descriptors = await mapDiscovery(
|
||||
loaded.descriptors,
|
||||
async (descriptor) => {
|
||||
try {
|
||||
const status = responseSchema.safeParse(
|
||||
await this.#request(
|
||||
descriptor,
|
||||
serializeRequest(descriptor, {
|
||||
kind: "status",
|
||||
session_id: descriptor.session_id,
|
||||
}),
|
||||
),
|
||||
);
|
||||
if (
|
||||
!status.success ||
|
||||
!status.data.ok ||
|
||||
status.data.session_id !== descriptor.session_id
|
||||
)
|
||||
return undefined;
|
||||
return {
|
||||
session_id: descriptor.session_id,
|
||||
pid: descriptor.pid,
|
||||
document_generation:
|
||||
status.data.document_generation ?? descriptor.document_generation,
|
||||
current_file:
|
||||
status.data.current_file === undefined
|
||||
? descriptor.current_file
|
||||
: status.data.current_file,
|
||||
cutter_version:
|
||||
status.data.cutter_version ?? descriptor.cutter_version,
|
||||
identity_status: "partial" as const,
|
||||
};
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
},
|
||||
);
|
||||
const sessions = descriptors.filter(
|
||||
(value): value is CutterBridgeSession => value !== undefined,
|
||||
);
|
||||
return {
|
||||
sessions,
|
||||
bridge_directory: this.#directory,
|
||||
discovery_status:
|
||||
sessions.length > 0 ? "sessions_found" : "no_live_bridge_found",
|
||||
bridge_directory_security: loaded.security,
|
||||
};
|
||||
}
|
||||
|
||||
async execute(input: {
|
||||
readonly sessionId: string;
|
||||
readonly expectedGeneration: number;
|
||||
readonly command: string;
|
||||
readonly json: boolean;
|
||||
readonly signal?: AbortSignal;
|
||||
}): Promise<CutterBridgeExecution> {
|
||||
input.signal?.throwIfAborted();
|
||||
const descriptor = await this.#find(input.sessionId);
|
||||
input.signal?.throwIfAborted();
|
||||
const requestId = randomUUID();
|
||||
const commandRequest = serializeRequest(descriptor, {
|
||||
kind: "command",
|
||||
request_id: requestId,
|
||||
expected_generation: input.expectedGeneration,
|
||||
command: input.command,
|
||||
json: input.json,
|
||||
});
|
||||
await this.#recoverCompletedCommands(descriptor, input.signal);
|
||||
await this.#retryPendingAcknowledgements(descriptor);
|
||||
input.signal?.throwIfAborted();
|
||||
let completionToken: string | undefined;
|
||||
let rawResponse: unknown;
|
||||
try {
|
||||
rawResponse = await this.#request(
|
||||
descriptor,
|
||||
commandRequest,
|
||||
input.signal,
|
||||
(ordinal) => {
|
||||
completionToken = ordinal;
|
||||
},
|
||||
);
|
||||
} catch (cause) {
|
||||
if (!(cause instanceof CutterBridgeRequestFailure) || !cause.requestSent)
|
||||
throw cause;
|
||||
return {
|
||||
requestId,
|
||||
output: null,
|
||||
currentFile: descriptor.current_file,
|
||||
documentGeneration: input.expectedGeneration,
|
||||
identityStatus: "partial",
|
||||
cutterVersion: descriptor.cutter_version,
|
||||
executionState: "unknown",
|
||||
error: "transport-response-missing",
|
||||
message: `${cause.message}; the command may have completed; do not retry automatically`,
|
||||
outputTruncated: false,
|
||||
...(completionToken === undefined ? {} : { completionToken }),
|
||||
};
|
||||
}
|
||||
const parsed = responseSchema.safeParse(rawResponse);
|
||||
if (!parsed.success)
|
||||
return {
|
||||
requestId,
|
||||
output: null,
|
||||
currentFile: descriptor.current_file,
|
||||
documentGeneration: input.expectedGeneration,
|
||||
identityStatus: "partial",
|
||||
cutterVersion: descriptor.cutter_version,
|
||||
executionState: "unknown",
|
||||
error: "malformed-bridge-response",
|
||||
message:
|
||||
"Cutter may have completed the command, but its response was invalid; do not retry automatically",
|
||||
outputTruncated: false,
|
||||
...(completionToken === undefined ? {} : { completionToken }),
|
||||
};
|
||||
const response = parsed.data;
|
||||
const executionState: CutterBridgeExecutionState = response.ok
|
||||
? response.execution_state === undefined ||
|
||||
response.execution_state === "complete"
|
||||
? "complete"
|
||||
: response.execution_state === "not_started"
|
||||
? "not_started"
|
||||
: "unknown"
|
||||
: response.execution_state === "failed"
|
||||
? "failed"
|
||||
: response.execution_state === "not_started"
|
||||
? "not_started"
|
||||
: "unknown";
|
||||
if (executionState !== "unknown")
|
||||
await this.#acknowledgeCompletion(descriptor, requestId);
|
||||
if (response.error === "active-document-generation-changed")
|
||||
throw new Error(
|
||||
"Cutter's active document generation changed. List sessions again and select the current generation.",
|
||||
);
|
||||
return {
|
||||
requestId,
|
||||
output: response.output ?? null,
|
||||
currentFile:
|
||||
response.current_file === undefined
|
||||
? descriptor.current_file
|
||||
: response.current_file,
|
||||
documentGeneration:
|
||||
response.document_generation ?? input.expectedGeneration,
|
||||
identityStatus: "partial",
|
||||
cutterVersion: response.cutter_version ?? descriptor.cutter_version,
|
||||
executionState,
|
||||
error: response.ok ? null : (response.error ?? "cutter-command-failed"),
|
||||
message: response.message ?? null,
|
||||
outputTruncated: response.output_truncated ?? false,
|
||||
...(completionToken === undefined ? {} : { completionToken }),
|
||||
};
|
||||
}
|
||||
|
||||
/** Confirm completion by exact receipt or authenticated connection watermark. */
|
||||
async confirmCommandCompletion(
|
||||
sessionId: string,
|
||||
requestId: string,
|
||||
completionToken?: string,
|
||||
): Promise<Result<boolean, AnalysisError>> {
|
||||
let descriptor: z.infer<typeof descriptorSchema>;
|
||||
try {
|
||||
descriptor = await this.#find(sessionId);
|
||||
} catch (cause) {
|
||||
return err(
|
||||
new AnalysisCapabilityUnavailableError(
|
||||
"cutter",
|
||||
"cutter_command",
|
||||
cause instanceof Error ? cause.message : "Cutter session unavailable",
|
||||
{ cause },
|
||||
),
|
||||
);
|
||||
}
|
||||
try {
|
||||
const raw = await this.#request(
|
||||
descriptor,
|
||||
serializeRequest(descriptor, {
|
||||
kind: "status",
|
||||
session_id: sessionId,
|
||||
}),
|
||||
);
|
||||
const parsed = responseSchema.safeParse(raw);
|
||||
if (
|
||||
!parsed.success ||
|
||||
!parsed.data.ok ||
|
||||
parsed.data.session_id !== sessionId
|
||||
)
|
||||
return err(
|
||||
new AnalysisOutputError(
|
||||
"cutter_command_status",
|
||||
"The bridge status response did not contain a valid session identity.",
|
||||
{ cause: parsed.success ? undefined : parsed.error },
|
||||
),
|
||||
);
|
||||
const completed = (parsed.data.completed_commands ?? []).some(
|
||||
(receipt) => receipt.request_id === requestId,
|
||||
);
|
||||
if (completed) await this.#acknowledgeCompletion(descriptor, requestId);
|
||||
return ok(
|
||||
completed ||
|
||||
(completionToken !== undefined &&
|
||||
parsed.data.completed_connection !== undefined &&
|
||||
parsed.data.completed_connection >= completionToken),
|
||||
);
|
||||
} catch (cause) {
|
||||
return err(
|
||||
new AnalysisCapabilityUnavailableError(
|
||||
"cutter",
|
||||
"cutter_command_status",
|
||||
cause instanceof Error ? cause.message : "Cutter status failed",
|
||||
{ cause },
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async #recoverCompletedCommands(
|
||||
descriptor: z.infer<typeof descriptorSchema>,
|
||||
signal?: AbortSignal,
|
||||
): Promise<void> {
|
||||
const raw = await this.#request(
|
||||
descriptor,
|
||||
serializeRequest(descriptor, {
|
||||
kind: "status",
|
||||
session_id: descriptor.session_id,
|
||||
}),
|
||||
signal,
|
||||
);
|
||||
const status = responseSchema.safeParse(raw);
|
||||
if (
|
||||
!status.success ||
|
||||
!status.data.ok ||
|
||||
status.data.session_id !== descriptor.session_id
|
||||
)
|
||||
throw new AnalysisOutputError(
|
||||
"cutter_command_status",
|
||||
"The bridge status response did not contain a valid session identity.",
|
||||
{ cause: status.success ? undefined : status.error },
|
||||
);
|
||||
if (status.data.completed_connection === undefined)
|
||||
throw new AnalysisOutputError(
|
||||
"cutter_command_status",
|
||||
"The bridge did not provide authenticated proof that earlier connections reached a terminal state.",
|
||||
);
|
||||
const pending =
|
||||
this.#pendingAcknowledgements.get(descriptor.session_id) ?? new Set();
|
||||
for (const receipt of status.data.completed_commands ?? [])
|
||||
pending.add(receipt.request_id);
|
||||
if (pending.size > 0)
|
||||
this.#pendingAcknowledgements.set(descriptor.session_id, pending);
|
||||
}
|
||||
|
||||
async #acknowledgeCompletion(
|
||||
descriptor: z.infer<typeof descriptorSchema>,
|
||||
requestId: string,
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
const raw = await this.#request(
|
||||
descriptor,
|
||||
serializeRequest(descriptor, {
|
||||
kind: "ack",
|
||||
request_id: requestId,
|
||||
}),
|
||||
);
|
||||
const acknowledged = acknowledgementSchema.safeParse(raw);
|
||||
if (!acknowledged.success)
|
||||
throw new Error(
|
||||
"Cutter did not confirm the completion receipt acknowledgement.",
|
||||
);
|
||||
const pending = this.#pendingAcknowledgements.get(descriptor.session_id);
|
||||
pending?.delete(requestId);
|
||||
if (pending?.size === 0)
|
||||
this.#pendingAcknowledgements.delete(descriptor.session_id);
|
||||
return true;
|
||||
} catch {
|
||||
const pending =
|
||||
this.#pendingAcknowledgements.get(descriptor.session_id) ??
|
||||
new Set<string>();
|
||||
pending.add(requestId);
|
||||
this.#pendingAcknowledgements.set(descriptor.session_id, pending);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async #retryPendingAcknowledgements(
|
||||
descriptor: z.infer<typeof descriptorSchema>,
|
||||
): Promise<void> {
|
||||
const pending = this.#pendingAcknowledgements.get(descriptor.session_id);
|
||||
if (pending === undefined) return;
|
||||
for (const requestId of pending) {
|
||||
if (!(await this.#acknowledgeCompletion(descriptor, requestId)))
|
||||
throw new AnalysisCapabilityUnavailableError(
|
||||
"cutter",
|
||||
"cutter_command",
|
||||
"A retained Cutter completion receipt could not be acknowledged; no new command was sent.",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async #find(sessionId: string): Promise<z.infer<typeof descriptorSchema>> {
|
||||
const loaded = await this.#readDescriptors();
|
||||
if (loaded.error === "bridge_directory_capacity_exceeded")
|
||||
throw new AnalysisResourceConstraintError(
|
||||
"cutter_command",
|
||||
"transport",
|
||||
"Cutter descriptor discovery exceeded its candidate budget; no command was sent.",
|
||||
{
|
||||
max_descriptor_candidates: MAX_DESCRIPTOR_CANDIDATES,
|
||||
bridge_directory: this.#directory,
|
||||
},
|
||||
);
|
||||
if (loaded.error !== undefined)
|
||||
throw new AnalysisCapabilityUnavailableError(
|
||||
"cutter",
|
||||
"cutter_command",
|
||||
`Cutter descriptor discovery failed: ${loaded.error}`,
|
||||
);
|
||||
const descriptor = loaded.descriptors.find(
|
||||
(candidate) => candidate.session_id === sessionId,
|
||||
);
|
||||
if (descriptor === undefined)
|
||||
throw new Error("Cutter bridge session is unavailable or stale");
|
||||
return descriptor;
|
||||
}
|
||||
|
||||
async listDescriptors(): Promise<
|
||||
readonly z.infer<typeof descriptorSchema>[]
|
||||
> {
|
||||
const loaded = await this.#readDescriptors();
|
||||
return loaded.descriptors;
|
||||
}
|
||||
|
||||
async #readDescriptors(): Promise<{
|
||||
readonly descriptors: readonly z.infer<typeof descriptorSchema>[];
|
||||
readonly security:
|
||||
| "private_verified"
|
||||
| "not_private"
|
||||
| "unverified_platform_acl"
|
||||
| "not_checked";
|
||||
readonly error?:
|
||||
| "bridge_directory_unavailable"
|
||||
| "bridge_directory_insecure"
|
||||
| "bridge_directory_capacity_exceeded";
|
||||
}> {
|
||||
let directoryInfo;
|
||||
try {
|
||||
directoryInfo = await lstat(this.#directory);
|
||||
} catch (cause) {
|
||||
const code = (cause as NodeJS.ErrnoException).code;
|
||||
if (code === "ENOENT")
|
||||
return { descriptors: [], security: "not_checked" };
|
||||
return {
|
||||
descriptors: [],
|
||||
security: "not_checked",
|
||||
error: "bridge_directory_unavailable",
|
||||
};
|
||||
}
|
||||
if (!directoryInfo.isDirectory() || directoryInfo.isSymbolicLink())
|
||||
return {
|
||||
descriptors: [],
|
||||
security: "not_private",
|
||||
error: "bridge_directory_insecure",
|
||||
};
|
||||
const currentUid =
|
||||
this.#platform !== "win32" ? process.getuid?.() : undefined;
|
||||
const posix = currentUid !== undefined;
|
||||
if (
|
||||
posix &&
|
||||
(directoryInfo.uid !== currentUid || (directoryInfo.mode & 0o077) !== 0)
|
||||
)
|
||||
return {
|
||||
descriptors: [],
|
||||
security: "not_private",
|
||||
error: "bridge_directory_insecure",
|
||||
};
|
||||
let security:
|
||||
| "private_verified"
|
||||
| "not_private"
|
||||
| "unverified_platform_acl" = posix
|
||||
? "private_verified"
|
||||
: "unverified_platform_acl";
|
||||
const windowsPrivateReader = this.#windowsPrivateReader;
|
||||
if (!posix) {
|
||||
if (this.#platform !== "win32" || windowsPrivateReader === undefined)
|
||||
return {
|
||||
descriptors: [],
|
||||
security,
|
||||
error: "bridge_directory_insecure",
|
||||
};
|
||||
try {
|
||||
if (!windowsPrivateReader.verifyDirectory(this.#directory))
|
||||
return {
|
||||
descriptors: [],
|
||||
security: "not_private",
|
||||
error: "bridge_directory_insecure",
|
||||
};
|
||||
security = "private_verified";
|
||||
} catch {
|
||||
return {
|
||||
descriptors: [],
|
||||
security,
|
||||
error: "bridge_directory_insecure",
|
||||
};
|
||||
}
|
||||
}
|
||||
const entries: string[] = [];
|
||||
try {
|
||||
const directory = await opendir(this.#directory);
|
||||
for await (const entry of directory) {
|
||||
if (!/^cutter-\d+-[0-9a-f-]{36}\.json$/iu.test(entry.name)) continue;
|
||||
if (entries.length === MAX_DESCRIPTOR_CANDIDATES)
|
||||
return {
|
||||
descriptors: [],
|
||||
security,
|
||||
error: "bridge_directory_capacity_exceeded",
|
||||
};
|
||||
entries.push(entry.name);
|
||||
}
|
||||
} catch {
|
||||
return {
|
||||
descriptors: [],
|
||||
security: "not_checked",
|
||||
error: "bridge_directory_unavailable",
|
||||
};
|
||||
}
|
||||
const loaded = await mapDiscovery(entries, async (entry) => {
|
||||
let file;
|
||||
try {
|
||||
let bytes: Buffer;
|
||||
if (this.#platform === "win32") {
|
||||
bytes = windowsPrivateReader!.readDescriptor(
|
||||
this.#directory,
|
||||
entry,
|
||||
MAX_DESCRIPTOR_BYTES,
|
||||
);
|
||||
} else {
|
||||
const flags = fsConstants.O_RDONLY | (fsConstants.O_NOFOLLOW ?? 0);
|
||||
file = await open(join(this.#directory, entry), flags);
|
||||
const info = await file.stat();
|
||||
if (
|
||||
!info.isFile() ||
|
||||
info.size > MAX_DESCRIPTOR_BYTES ||
|
||||
info.uid !== currentUid ||
|
||||
(info.mode & 0o077) !== 0
|
||||
)
|
||||
return undefined;
|
||||
const boundedBytes = await readBoundedFileBytes(
|
||||
file,
|
||||
MAX_DESCRIPTOR_BYTES,
|
||||
);
|
||||
if (boundedBytes === undefined) return undefined;
|
||||
bytes = boundedBytes;
|
||||
}
|
||||
if (bytes.byteLength > MAX_DESCRIPTOR_BYTES) return undefined;
|
||||
const parsed = descriptorSchema.safeParse(
|
||||
JSON.parse(bytes.toString("utf8")) as unknown,
|
||||
);
|
||||
if (
|
||||
!parsed.success ||
|
||||
entry !== `cutter-${parsed.data.pid}-${parsed.data.session_id}.json`
|
||||
)
|
||||
return undefined;
|
||||
return parsed.data;
|
||||
} catch {
|
||||
return undefined;
|
||||
} finally {
|
||||
await file?.close().catch(() => undefined);
|
||||
}
|
||||
});
|
||||
return {
|
||||
descriptors: loaded.filter(
|
||||
(value): value is z.infer<typeof descriptorSchema> =>
|
||||
value !== undefined,
|
||||
),
|
||||
security,
|
||||
};
|
||||
}
|
||||
|
||||
#request(
|
||||
descriptor: z.infer<typeof descriptorSchema>,
|
||||
requestBytes: AuthenticatedRequestBytes,
|
||||
signal?: AbortSignal,
|
||||
onAuthenticated?: (connectionOrdinal: string) => void,
|
||||
): Promise<unknown> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const connection = createConnection({
|
||||
host: descriptor.host,
|
||||
port: descriptor.port,
|
||||
});
|
||||
const frames = new CutterBridgeResponseFrames();
|
||||
let settled = false;
|
||||
let requestSent = false;
|
||||
let authenticated = false;
|
||||
const clientNonce = randomBytes(32).toString("hex");
|
||||
let serverNonce = "";
|
||||
let connectionOrdinal: string | undefined;
|
||||
const finish = (complete: () => void): void => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
signal?.removeEventListener("abort", onAbort);
|
||||
complete();
|
||||
};
|
||||
const rejectRequest = (message: string): void =>
|
||||
finish(() =>
|
||||
reject(new CutterBridgeRequestFailure(message, requestSent)),
|
||||
);
|
||||
const timer = setTimeout(() => {
|
||||
rejectRequest("Cutter bridge request timed out");
|
||||
connection.destroy();
|
||||
}, 25_000);
|
||||
const onAbort = (): void => {
|
||||
rejectRequest("Cutter bridge request was cancelled");
|
||||
connection.destroy();
|
||||
};
|
||||
signal?.addEventListener("abort", onAbort, { once: true });
|
||||
if (signal?.aborted) onAbort();
|
||||
connection.once("connect", () => {
|
||||
connection.write(
|
||||
`${JSON.stringify({ kind: "rea-auth-v2", nonce: clientNonce })}\n`,
|
||||
);
|
||||
});
|
||||
connection.on("data", (chunk: Buffer) => {
|
||||
try {
|
||||
frames.push(chunk, (line) => {
|
||||
const frame: unknown = JSON.parse(line.toString("utf8"));
|
||||
if (!authenticated) {
|
||||
const challenge = z
|
||||
.object({
|
||||
session_id: z.string().uuid(),
|
||||
nonce: z.string().regex(/^[0-9a-f]{64}$/),
|
||||
proof: z.string().regex(/^[0-9a-f]{64}$/),
|
||||
})
|
||||
.parse(frame);
|
||||
if (challenge.session_id !== descriptor.session_id)
|
||||
throw new Error("unexpected peer session");
|
||||
serverNonce = challenge.nonce;
|
||||
connectionOrdinal = challenge.nonce.slice(0, 16);
|
||||
const expected = createHmac("sha256", descriptor.token)
|
||||
.update(
|
||||
`rea-cutter-server-v2:${descriptor.session_id}:${clientNonce}:${challenge.nonce}`,
|
||||
)
|
||||
.digest();
|
||||
if (
|
||||
!timingSafeEqual(expected, Buffer.from(challenge.proof, "hex"))
|
||||
)
|
||||
throw new Error("peer proof failed");
|
||||
onAuthenticated?.(connectionOrdinal);
|
||||
const proof = createHmac("sha256", descriptor.token)
|
||||
.update(
|
||||
`rea-cutter-client-v2:${descriptor.session_id}:${clientNonce}:${challenge.nonce}:${requestBytes}`,
|
||||
)
|
||||
.digest("hex");
|
||||
authenticated = true;
|
||||
requestSent = true;
|
||||
connection.write(
|
||||
`${JSON.stringify({ request: requestBytes, proof })}\n`,
|
||||
);
|
||||
return true;
|
||||
} else {
|
||||
const envelope = z
|
||||
.object({
|
||||
response: z.string(),
|
||||
proof: z.string().regex(/^[0-9a-f]{64}$/),
|
||||
})
|
||||
.parse(frame);
|
||||
const expected = createHmac("sha256", descriptor.token)
|
||||
.update(
|
||||
`rea-cutter-response-v2:${descriptor.session_id}:${clientNonce}:${serverNonce}:${envelope.response}`,
|
||||
)
|
||||
.digest();
|
||||
if (
|
||||
!timingSafeEqual(expected, Buffer.from(envelope.proof, "hex"))
|
||||
)
|
||||
throw new Error("response proof failed");
|
||||
const response: unknown = JSON.parse(envelope.response);
|
||||
connection.end();
|
||||
finish(() => resolve(response));
|
||||
return false;
|
||||
}
|
||||
});
|
||||
} catch {
|
||||
rejectRequest("Cutter bridge peer authentication or response failed");
|
||||
connection.destroy();
|
||||
}
|
||||
});
|
||||
connection.once("error", (cause) =>
|
||||
rejectRequest(`Cutter bridge is unavailable: ${cause.message}`),
|
||||
);
|
||||
connection.once("close", () =>
|
||||
rejectRequest(
|
||||
"Cutter bridge closed before returning a complete response",
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,192 @@
|
|||
import { jsonValueSchema } from "../domain/jsonValue.js";
|
||||
import {
|
||||
AnalysisCancelledError,
|
||||
AnalysisCapabilityUnavailableError,
|
||||
AnalysisInputError,
|
||||
AnalysisOutputError,
|
||||
} from "../domain/analysisErrorCore.js";
|
||||
import { AnalysisError } from "../domain/analysisErrorBase.js";
|
||||
import { createEvidence, type Evidence } from "../domain/evidence.js";
|
||||
import { err, ok, type Result } from "../domain/result.js";
|
||||
import { cutterCommandInputSchema } from "../contracts/cutterToolContracts.js";
|
||||
import {
|
||||
CutterBridgeClient,
|
||||
type CutterBridgeDiscovery,
|
||||
type CutterBridgeExecution,
|
||||
} from "./CutterBridgeClient.js";
|
||||
import { CUTTER_PROVIDER_IDENTITY } from "./CutterBridgeClient.js";
|
||||
|
||||
interface CutterBridgePort {
|
||||
listSessions(): Promise<CutterBridgeDiscovery>;
|
||||
confirmCommandCompletion?(
|
||||
sessionId: string,
|
||||
requestId: string,
|
||||
completionToken?: string,
|
||||
): Promise<Result<boolean, AnalysisError>>;
|
||||
execute(input: {
|
||||
readonly sessionId: string;
|
||||
readonly expectedGeneration: number;
|
||||
readonly command: string;
|
||||
readonly json: boolean;
|
||||
readonly signal?: AbortSignal;
|
||||
}): Promise<CutterBridgeExecution>;
|
||||
}
|
||||
|
||||
/** Shared Cutter bridge workflows used by the MCP adapter and CLI. */
|
||||
export class CutterBridgeService {
|
||||
#cleanupAttempt: Promise<Result<null, AnalysisError>> | undefined;
|
||||
readonly #pendingCleanup = new Map<
|
||||
string,
|
||||
{
|
||||
readonly sessionId: string;
|
||||
readonly completionToken?: string;
|
||||
readonly failure: AnalysisOutputError;
|
||||
}
|
||||
>();
|
||||
|
||||
constructor(readonly client: CutterBridgePort = new CutterBridgeClient()) {}
|
||||
|
||||
/** Release timed-out work only after producer-owned terminal proof. */
|
||||
retryCleanup(): Promise<Result<null, AnalysisError>> {
|
||||
if (this.#cleanupAttempt !== undefined) return this.#cleanupAttempt;
|
||||
const attempt = this.#retryPendingCleanup().finally(() => {
|
||||
if (this.#cleanupAttempt === attempt) this.#cleanupAttempt = undefined;
|
||||
});
|
||||
this.#cleanupAttempt = attempt;
|
||||
return attempt;
|
||||
}
|
||||
|
||||
async #retryPendingCleanup(): Promise<Result<null, AnalysisError>> {
|
||||
for (const [requestId, pending] of this.#pendingCleanup) {
|
||||
const confirmCommandCompletion = this.client.confirmCommandCompletion;
|
||||
if (confirmCommandCompletion === undefined) return err(pending.failure);
|
||||
const receipt = await confirmCommandCompletion.call(
|
||||
this.client,
|
||||
pending.sessionId,
|
||||
requestId,
|
||||
pending.completionToken,
|
||||
);
|
||||
if (!receipt.ok || !receipt.value) return err(pending.failure);
|
||||
const { failure } = pending;
|
||||
failure.resolveCleanup();
|
||||
this.#pendingCleanup.delete(requestId);
|
||||
}
|
||||
return ok(null);
|
||||
}
|
||||
|
||||
listSessions(): Promise<CutterBridgeDiscovery> {
|
||||
return this.client.listSessions();
|
||||
}
|
||||
|
||||
async execute(
|
||||
rawInput: unknown,
|
||||
options: { readonly signal?: AbortSignal } = {},
|
||||
): Promise<Result<Evidence, AnalysisError>> {
|
||||
const parsed = cutterCommandInputSchema.safeParse(rawInput);
|
||||
if (!parsed.success) return err(new AnalysisInputError("cutter_command"));
|
||||
if (options.signal?.aborted)
|
||||
return err(new AnalysisCancelledError("cutter_command"));
|
||||
const input = parsed.data;
|
||||
const cleanup = await this.retryCleanup();
|
||||
if (!cleanup.ok) return err(cleanup.error);
|
||||
try {
|
||||
const output = await this.client.execute({
|
||||
sessionId: input.session_id,
|
||||
expectedGeneration: input.expected_generation,
|
||||
command: input.command,
|
||||
json: input.json,
|
||||
...(options.signal === undefined ? {} : { signal: options.signal }),
|
||||
});
|
||||
const normalizedOutput = jsonValueSchema.parse(output.output);
|
||||
const evidence = createEvidence(
|
||||
undefined,
|
||||
{
|
||||
...CUTTER_PROVIDER_IDENTITY,
|
||||
version: output.cutterVersion,
|
||||
},
|
||||
{
|
||||
predicateType: "rea.cutter.command-observation",
|
||||
operation: "cutter_command",
|
||||
parameters: {
|
||||
session_id: input.session_id,
|
||||
expected_generation: input.expected_generation,
|
||||
command: input.command,
|
||||
json: input.json,
|
||||
},
|
||||
result: {
|
||||
command: input.command,
|
||||
output: normalizedOutput,
|
||||
execution_state: output.executionState,
|
||||
error: output.error,
|
||||
message: output.message,
|
||||
output_truncated: output.outputTruncated,
|
||||
cutter_version: output.cutterVersion,
|
||||
current_file: output.currentFile,
|
||||
document_generation: output.documentGeneration,
|
||||
identity_status: output.identityStatus,
|
||||
},
|
||||
rawResult: normalizedOutput,
|
||||
subjectUnavailableReason:
|
||||
"Cutter exposes no verified stable identity for the active unsaved document or tab.",
|
||||
limitations: [
|
||||
"Document identity is partial: the bridge uses a plugin instance UUID and observed generation; a same-path tab switch or reload may not be distinguishable.",
|
||||
"Cutter command effects depend on the Rizin command and active plugins.",
|
||||
"The local IPC protocol is implemented by REA and is not an upstream Cutter API.",
|
||||
...(output.executionState === "unknown"
|
||||
? [
|
||||
"Command completion or its output could not be confirmed; do not retry automatically because the command may have produced partial or persistent effects.",
|
||||
]
|
||||
: []),
|
||||
...(output.outputTruncated
|
||||
? [
|
||||
"The command completed or may have completed, but output exceeded the bridge transport limit and was omitted.",
|
||||
]
|
||||
: []),
|
||||
],
|
||||
},
|
||||
);
|
||||
if (output.executionState === "unknown") {
|
||||
const failure = new AnalysisOutputError(
|
||||
"cutter_command",
|
||||
"Cutter command completion is unknown; the bound binary remains unavailable until the UI dispatcher confirms this request reached a terminal state.",
|
||||
{
|
||||
cleanup: {
|
||||
reason:
|
||||
"The exact UI dispatcher completion receipt has not arrived; the dispatched command may still be running.",
|
||||
resources: [`cutter-session:${input.session_id}`],
|
||||
},
|
||||
partialObservation: evidence,
|
||||
},
|
||||
);
|
||||
this.#pendingCleanup.set(output.requestId, {
|
||||
sessionId: input.session_id,
|
||||
...(output.completionToken === undefined
|
||||
? {}
|
||||
: { completionToken: output.completionToken }),
|
||||
failure,
|
||||
});
|
||||
return err(failure);
|
||||
}
|
||||
return ok(evidence);
|
||||
} catch (cause) {
|
||||
if (cause instanceof AnalysisError) return err(cause);
|
||||
const message =
|
||||
cause instanceof Error
|
||||
? cause.message
|
||||
: "Unknown Cutter bridge failure";
|
||||
if (options.signal?.aborted && !message.includes("may have completed"))
|
||||
return err(new AnalysisCancelledError("cutter_command", { cause }));
|
||||
return err(
|
||||
message.includes("generation changed") ||
|
||||
message.includes("unavailable or stale")
|
||||
? new AnalysisInputError("cutter_command", { cause })
|
||||
: new AnalysisCapabilityUnavailableError(
|
||||
"cutter",
|
||||
"cutter_command",
|
||||
message,
|
||||
{ cause },
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -65,7 +65,7 @@ export abstract class AnalysisError extends Error {
|
|||
readonly diagnostics: Readonly<Record<string, JsonValue>> | undefined;
|
||||
readonly capturedOutput: AnalysisCapturedOutput | undefined;
|
||||
readonly cleanup: AnalysisCleanupObservation | undefined;
|
||||
readonly cleanupIncomplete: boolean;
|
||||
cleanupIncomplete: boolean;
|
||||
readonly cleanupResources: readonly string[];
|
||||
constructor(message: string, options?: AnalysisErrorOptions) {
|
||||
super(message, options);
|
||||
|
|
@ -89,6 +89,30 @@ export abstract class AnalysisError extends Error {
|
|||
partialObservation: AnalysisPartialObservation | undefined;
|
||||
readonly cleanupReport: ProcessCaptureCleanupReport | undefined = undefined;
|
||||
|
||||
/** Retain a later lifecycle failure without replacing the original typed error. */
|
||||
retainSecondaryFailure(
|
||||
failure: AnalysisError,
|
||||
diagnostics: Readonly<Record<string, JsonValue>>,
|
||||
): this {
|
||||
// Secondary resources remain owned by their cleanup lifecycle; preserve
|
||||
// the primary error's live cleanup status and original output.
|
||||
Object.assign(this, {
|
||||
diagnostics: structuredClone({ ...this.diagnostics, ...diagnostics }),
|
||||
cause: new AggregateError(
|
||||
this.cause === undefined ? [failure] : [this.cause, failure],
|
||||
"Analysis failed and a later lifecycle operation also failed.",
|
||||
{ cause: this.cause },
|
||||
),
|
||||
});
|
||||
return this;
|
||||
}
|
||||
|
||||
/** Clear live cleanup status only after its owning resource confirms retry success. */
|
||||
resolveCleanup(): this {
|
||||
this.cleanupIncomplete = false;
|
||||
return this;
|
||||
}
|
||||
|
||||
/** Retain a completed observation when a later step fails. */
|
||||
retainPartialObservation(observation: AnalysisPartialObservation): this {
|
||||
this.partialObservation ??= observation;
|
||||
|
|
|
|||
|
|
@ -86,13 +86,15 @@ export interface AnalysisInputIssue {
|
|||
/** Provider output failed provider-neutral application parsing. */
|
||||
export class AnalysisOutputError extends AnalysisError {
|
||||
readonly _tag = "AnalysisOutputError";
|
||||
readonly cleanupOnly: boolean;
|
||||
|
||||
constructor(
|
||||
readonly operation: string,
|
||||
readonly reason: string,
|
||||
options?: AnalysisErrorOptions,
|
||||
options?: AnalysisErrorOptions & { readonly cleanupOnly?: boolean },
|
||||
) {
|
||||
super(`Invalid analysis output for ${operation}: ${reason}`, options);
|
||||
this.cleanupOnly = options?.cleanupOnly ?? false;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -796,3 +796,32 @@ describe("analysis error projection: evidence files", () => {
|
|||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("retains the primary failure and both cleanup reports after a secondary failure", () => {
|
||||
const primary = new AnalysisCancelledError("execute_rizin_command", {
|
||||
capturedOutput: {
|
||||
stdout: "partial",
|
||||
stderr: "diagnostic",
|
||||
truncated: false,
|
||||
},
|
||||
cleanup: { reason: "writer remains", resources: ["writer"] },
|
||||
});
|
||||
const secondary = new AnalysisOutputError("open_binary", "cannot rebind", {
|
||||
cleanup: { reason: "provider remains", resources: ["provider", "writer"] },
|
||||
});
|
||||
const enriched = primary.retainSecondaryFailure(secondary, {
|
||||
rebind_error: projectAnalysisError(secondary),
|
||||
});
|
||||
expect(enriched).toBe(primary);
|
||||
expect(enriched._tag).toBe("AnalysisCancelledError");
|
||||
expect(projectAnalysisError(enriched)).toMatchObject({
|
||||
code: "cleanup_incomplete",
|
||||
details: {
|
||||
execution_failure: "cancelled",
|
||||
resources: ["writer"],
|
||||
cleanup_reason: "writer remains",
|
||||
captured_output: { stdout: "partial", stderr: "diagnostic" },
|
||||
diagnostics: { rebind_error: { code: "cleanup_incomplete" } },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -447,8 +447,8 @@ const lifecycleErrorDetails = (
|
|||
if (error instanceof AnalysisCancelledError)
|
||||
return {
|
||||
operation: error.operation,
|
||||
cleanup: error.cleanup === undefined ? "complete" : "incomplete",
|
||||
...(error.cleanup === undefined
|
||||
cleanup: error.cleanupIncomplete ? "incomplete" : "complete",
|
||||
...(error.cleanup === undefined || !error.cleanupIncomplete
|
||||
? {}
|
||||
: {
|
||||
cleanup_reason: error.cleanup.reason,
|
||||
|
|
@ -462,7 +462,7 @@ const lifecycleErrorDetails = (
|
|||
return {
|
||||
operation: error.operation,
|
||||
timeout_ms: error.timeoutMs,
|
||||
...(error.cleanup === undefined
|
||||
...(error.cleanup === undefined || !error.cleanupIncomplete
|
||||
? {}
|
||||
: {
|
||||
cleanup: "incomplete",
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ import { AnalysisCancelledError } from "./analysisErrorCore.js";
|
|||
/** A bounded passive browser observation failed at its CDP boundary. */
|
||||
export class BrowserObservationError extends AnalysisError {
|
||||
readonly _tag = "BrowserObservationError";
|
||||
override readonly cleanupIncomplete: boolean;
|
||||
override cleanupIncomplete: boolean;
|
||||
override readonly cleanupResources: readonly string[];
|
||||
override readonly userCategory: "cancelled" | undefined;
|
||||
override readonly userMessage: string | undefined;
|
||||
|
|
|
|||
|
|
@ -72,13 +72,14 @@ const windowsHeaderSignature = (
|
|||
bytes: Buffer | undefined,
|
||||
): "PE" | "NE" | "LE" | "LX" | "other" | "unobserved" => {
|
||||
if (bytes === undefined || bytes.length < 2) return "unobserved";
|
||||
const prefix = bytes.toString("latin1", 0, 2);
|
||||
if (prefix === "NE" || prefix === "LE" || prefix === "LX") return prefix;
|
||||
if (prefix !== "PE") return "other";
|
||||
const first = bytes[0];
|
||||
const second = bytes[1];
|
||||
if (first === 0x4e && second === 0x45) return "NE";
|
||||
if (first === 0x4c && second === 0x45) return "LE";
|
||||
if (first === 0x4c && second === 0x58) return "LX";
|
||||
if (first !== 0x50 || second !== 0x45) return "other";
|
||||
if (bytes.length < 4) return "unobserved";
|
||||
return bytes.subarray(0, 4).equals(Buffer.from("PE\0\0", "binary"))
|
||||
? "PE"
|
||||
: "other";
|
||||
return bytes[2] === 0 && bytes[3] === 0 ? "PE" : "other";
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -78,9 +78,21 @@ describe("Hopper boundary values", () => {
|
|||
(_label, parse) => {
|
||||
const result = parse();
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) expect(result.error._tag).toBe("HopperProtocolError");
|
||||
if (!result.ok) expect(result.error._tag).toBe("AnalysisOutputError");
|
||||
},
|
||||
);
|
||||
|
||||
it("uses provider-neutral wording for malformed shared values", () => {
|
||||
const result = parseSegments([{ name: 1 }]);
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.error._tag).toBe("AnalysisOutputError");
|
||||
expect(result.error.operation).toBe("list_segments");
|
||||
expect(result.error.reason).toBe(
|
||||
"Provider returned an invalid segment list",
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("provider-neutral inclusive function body evidence", () => {
|
||||
|
|
|
|||
|
|
@ -7,7 +7,6 @@ import { err, ok, type Result } from "./result.js";
|
|||
import { nativeApiBoundarySchema } from "./native/nativeApiBoundary.js";
|
||||
import { nativeValueFlowSchema } from "./native/nativeValueFlow.js";
|
||||
import { AnalysisOutputError } from "./analysisErrorCore.js";
|
||||
import { HopperProtocolError } from "./hopperErrors.js";
|
||||
|
||||
export interface AddressedName {
|
||||
readonly address: string;
|
||||
|
|
@ -460,10 +459,11 @@ export const parseFunctionDossier = (
|
|||
);
|
||||
};
|
||||
|
||||
/** Parse Hopper's direct or wrapped procedure map into stable entries. */
|
||||
/** Parse a provider's direct or wrapped procedure map into stable entries. */
|
||||
export const parseProcedures = (
|
||||
value: JsonValue,
|
||||
): Result<readonly AddressedName[], HopperProtocolError> => {
|
||||
operation = "list_procedures",
|
||||
): Result<readonly AddressedName[], AnalysisOutputError> => {
|
||||
const parsed = procedureMapSchema.safeParse(
|
||||
unwrapProperty(value, "procedures"),
|
||||
);
|
||||
|
|
@ -474,43 +474,48 @@ export const parseProcedures = (
|
|||
name,
|
||||
})),
|
||||
)
|
||||
: invalid("procedure map", parsed.error);
|
||||
: invalid(operation, "procedure map", parsed.error);
|
||||
};
|
||||
|
||||
/** Parse Hopper's direct or wrapped list of address/name records. */
|
||||
/** Parse a provider's direct or wrapped list of address/name records. */
|
||||
export const parseNames = (
|
||||
value: JsonValue,
|
||||
): Result<readonly AddressedName[], HopperProtocolError> => {
|
||||
operation = "list_names",
|
||||
): Result<readonly AddressedName[], AnalysisOutputError> => {
|
||||
const unwrapped = unwrapProperty(value, "names");
|
||||
const records = addressedNamesSchema.safeParse(unwrapped);
|
||||
if (records.success) return ok(records.data);
|
||||
const map = addressedNameMapSchema.safeParse(unwrapped);
|
||||
return map.success
|
||||
? ok(Object.entries(map.data).map(([address, name]) => ({ address, name })))
|
||||
: invalid("name list", map.error);
|
||||
: invalid(operation, "name list", map.error);
|
||||
};
|
||||
|
||||
/** Parse callee/caller strings from direct or wrapped Hopper results. */
|
||||
/** Parse callee/caller strings from direct or wrapped provider results. */
|
||||
export const parseRelatedAddresses = (
|
||||
value: JsonValue,
|
||||
relation: "callees" | "callers",
|
||||
): Result<readonly string[], HopperProtocolError> => {
|
||||
operation = relation === "callees"
|
||||
? "procedure_callees"
|
||||
: "procedure_callers",
|
||||
): Result<readonly string[], AnalysisOutputError> => {
|
||||
const parsed = z.array(z.string()).safeParse(unwrapProperty(value, relation));
|
||||
return parsed.success
|
||||
? ok(parsed.data)
|
||||
: invalid(`${relation} list`, parsed.error);
|
||||
: invalid(operation, `${relation} list`, parsed.error);
|
||||
};
|
||||
|
||||
/** Preserve reported address context in direct or wrapped provider segments. */
|
||||
export const parseSegments = (
|
||||
value: JsonValue,
|
||||
): Result<readonly SegmentSummary[], HopperProtocolError> => {
|
||||
operation = "list_segments",
|
||||
): Result<readonly SegmentSummary[], AnalysisOutputError> => {
|
||||
const parsed = z
|
||||
.array(segmentSchema)
|
||||
.safeParse(unwrapProperty(value, "segments"));
|
||||
return parsed.success
|
||||
? ok(parsed.data)
|
||||
: invalid("segment list", parsed.error);
|
||||
: invalid(operation, "segment list", parsed.error);
|
||||
};
|
||||
|
||||
/** Return a reported overview base only when every parsed segment agrees. */
|
||||
|
|
@ -524,30 +529,32 @@ export const parseSegmentImageBase = (
|
|||
: undefined;
|
||||
};
|
||||
|
||||
/** Parse direct or wrapped Hopper document names. */
|
||||
/** Parse direct or wrapped provider document names. */
|
||||
export const parseDocuments = (
|
||||
value: JsonValue,
|
||||
): Result<readonly string[], HopperProtocolError> => {
|
||||
operation = "list_documents",
|
||||
): Result<readonly string[], AnalysisOutputError> => {
|
||||
const parsed = z
|
||||
.array(z.string())
|
||||
.safeParse(unwrapProperty(value, "documents"));
|
||||
return parsed.success
|
||||
? ok(parsed.data)
|
||||
: invalid("document list", parsed.error);
|
||||
: invalid(operation, "document list", parsed.error);
|
||||
};
|
||||
|
||||
/** Parse a direct or wrapped list when only its cardinality is required. */
|
||||
export const parseListCount = (
|
||||
value: JsonValue,
|
||||
property: string,
|
||||
): Result<number, HopperProtocolError> => {
|
||||
operation = property === "procedures" ? "list_procedures" : "list_strings",
|
||||
): Result<number, AnalysisOutputError> => {
|
||||
const unwrapped = unwrapProperty(value, property);
|
||||
const list = z.array(z.unknown()).safeParse(unwrapped);
|
||||
if (list.success) return ok(list.data.length);
|
||||
const map = z.record(z.string(), z.unknown()).safeParse(unwrapped);
|
||||
return map.success
|
||||
? ok(Object.keys(map.data).length)
|
||||
: invalid(`${property} list`, map.error);
|
||||
: invalid(operation, `${property} list`, map.error);
|
||||
};
|
||||
|
||||
const unwrapProperty = (value: JsonValue, property: string): JsonValue => {
|
||||
|
|
@ -563,11 +570,16 @@ const unwrapProperty = (value: JsonValue, property: string): JsonValue => {
|
|||
};
|
||||
|
||||
const invalid = <T>(
|
||||
operation: string,
|
||||
expected: string,
|
||||
cause: z.ZodError,
|
||||
): Result<T, HopperProtocolError> =>
|
||||
): Result<T, AnalysisOutputError> =>
|
||||
err(
|
||||
new HopperProtocolError(`Hopper returned an invalid ${expected}`, {
|
||||
new AnalysisOutputError(
|
||||
operation,
|
||||
`Provider returned an invalid ${expected}`,
|
||||
{
|
||||
cause,
|
||||
}),
|
||||
},
|
||||
),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import type { AnalysisPartialObservation } from "./analysisErrorBase.js";
|
|||
|
||||
/** Provider resources could not be proven closed after bounded cleanup. */
|
||||
export class ProviderCleanupError extends ProviderAdapterError {
|
||||
override readonly cleanupIncomplete = true;
|
||||
override cleanupIncomplete = true;
|
||||
override readonly cleanupResources: readonly string[];
|
||||
override readonly userMessage =
|
||||
"Provider cleanup could not be fully confirmed. Review the reported local resources before opening another target.";
|
||||
|
|
|
|||
|
|
@ -0,0 +1,40 @@
|
|||
import { z } from "zod";
|
||||
|
||||
const objdumpArtifactInputSchema = z
|
||||
.object({
|
||||
path: z.string().min(1),
|
||||
operation: z.enum([
|
||||
"file_headers",
|
||||
"section_headers",
|
||||
"symbols",
|
||||
"relocations",
|
||||
"dwarf",
|
||||
"disassemble",
|
||||
"disassemble_all",
|
||||
]),
|
||||
follow_debug_links: z.boolean().default(false),
|
||||
})
|
||||
.strict();
|
||||
|
||||
/** Caller-selected artifact facet or pathless provider capability query. */
|
||||
export const objdumpInputSchema = z.discriminatedUnion("operation", [
|
||||
objdumpArtifactInputSchema,
|
||||
z.strictObject({ operation: z.literal("architectures") }),
|
||||
]);
|
||||
|
||||
/** Caller-selected Rizin command against one local artifact. */
|
||||
export const rizinInputSchema = z
|
||||
.object({
|
||||
path: z.string().min(1),
|
||||
command: z
|
||||
.string()
|
||||
.min(1)
|
||||
.refine(
|
||||
(value) => !/[\r\n\u0000]/u.test(value),
|
||||
"A single command line is required",
|
||||
),
|
||||
})
|
||||
.strict();
|
||||
|
||||
export type ObjdumpInput = z.input<typeof objdumpInputSchema>;
|
||||
export type RizinInput = z.input<typeof rizinInputSchema>;
|
||||
|
|
@ -4,7 +4,7 @@ import type { AnalysisErrorOptions } from "./analysisErrorBase.js";
|
|||
/** Publication failure retaining its input/output target and cleanup state. */
|
||||
export class WebScriptExportError extends ArtifactOperationError {
|
||||
override readonly userMessage: string;
|
||||
override readonly cleanupIncomplete: boolean;
|
||||
override cleanupIncomplete: boolean;
|
||||
override readonly cleanupResources: readonly string[];
|
||||
|
||||
constructor(
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,24 @@
|
|||
import { resolve } from "node:path";
|
||||
|
||||
import type { ObjdumpInput } from "../domain/reverseEngineering.js";
|
||||
|
||||
export const OBJDUMP_PROVIDER_IDENTITY = {
|
||||
id: "gnu.objdump",
|
||||
name: "GNU objdump",
|
||||
version: null,
|
||||
} as const;
|
||||
|
||||
/** Build GNU objdump argv without invoking a shell or following DWARF links by default. */
|
||||
export const objdumpCommand = (input: ObjdumpInput): readonly string[] => {
|
||||
if (input.operation === "architectures") return ["-i"];
|
||||
const argumentsByOperation = {
|
||||
file_headers: ["-f"],
|
||||
section_headers: ["-h"],
|
||||
symbols: ["-t"],
|
||||
relocations: ["-r"],
|
||||
dwarf: ["-W", input.follow_debug_links ? "-WK" : "-WN"],
|
||||
disassemble: ["-d"],
|
||||
disassemble_all: ["-D"],
|
||||
} satisfies Record<typeof input.operation, readonly string[]>;
|
||||
return [...argumentsByOperation[input.operation], resolve(input.path)];
|
||||
};
|
||||
|
|
@ -1,13 +1,49 @@
|
|||
import { access, readFile } from "node:fs/promises";
|
||||
import { access, readFile, rm } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { createTestTempDirectory } from "../../tests/fixtures/temporaryDirectory.js";
|
||||
|
||||
import { execFileBytes, execFileOutput } from "./ExecFileOutput.js";
|
||||
import {
|
||||
execFileBytes,
|
||||
execFileOutput,
|
||||
execFileOutputFailure,
|
||||
} from "./ExecFileOutput.js";
|
||||
|
||||
const waitForFile = async (path: string): Promise<boolean> => {
|
||||
for (let attempt = 0; attempt < 500; attempt += 1) {
|
||||
if (
|
||||
await access(path).then(
|
||||
() => true,
|
||||
() => false,
|
||||
)
|
||||
)
|
||||
return true;
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
}
|
||||
return access(path).then(
|
||||
() => true,
|
||||
() => false,
|
||||
);
|
||||
};
|
||||
|
||||
describe("execFileOutput", () => {
|
||||
it("ignores non-Error and incomplete failure metadata", () => {
|
||||
expect(execFileOutputFailure("failure")).toBeUndefined();
|
||||
expect(execFileOutputFailure(new Error("failure"))).toBeUndefined();
|
||||
});
|
||||
|
||||
it("recognizes the execFile output limit code as truncated output", () => {
|
||||
const failure = Object.assign(new Error("output limit"), {
|
||||
stdout: "partial stdout",
|
||||
stderr: "partial stderr",
|
||||
code: "ERR_CHILD_PROCESS_STDIO_MAXBUFFER",
|
||||
});
|
||||
|
||||
expect(execFileOutputFailure(failure)?.outputTruncated).toBe(true);
|
||||
});
|
||||
|
||||
it("preserves captured bytes for callers that own the output format", async () => {
|
||||
const result = await execFileBytes(process.execPath, [
|
||||
"-e",
|
||||
|
|
@ -18,6 +54,20 @@ describe("execFileOutput", () => {
|
|||
expect(result.stderr).toEqual(Buffer.from([0xfe]));
|
||||
});
|
||||
|
||||
it("reports raw byte counts when UTF-8 decoding changes the captured text", async () => {
|
||||
const result = await execFileOutput(process.execPath, [
|
||||
"-e",
|
||||
"process.stdout.write(Buffer.from([0x61, 0xff])); process.stderr.write(Buffer.from([0xe2, 0x82, 0xac]));",
|
||||
]);
|
||||
|
||||
expect(result).toMatchObject({
|
||||
stdout: "a<>",
|
||||
stderr: "€",
|
||||
stdoutBytes: 2,
|
||||
stderrBytes: 3,
|
||||
});
|
||||
});
|
||||
|
||||
it("captures output larger than Node's default execFile limit", async () => {
|
||||
const marker = "tail-marker";
|
||||
const result = await execFileOutput(process.execPath, [
|
||||
|
|
@ -48,6 +98,14 @@ describe("execFileOutput", () => {
|
|||
).rejects.toMatchObject({ code: 7, stdout: "out", stderr: "err" });
|
||||
});
|
||||
|
||||
it("uses the built-in execFile timeout when no stop signal is requested", async () => {
|
||||
await expect(
|
||||
execFileOutput(process.execPath, ["-e", "setInterval(() => {}, 1000)"], {
|
||||
timeout: 100,
|
||||
}),
|
||||
).rejects.toMatchObject({ killed: true });
|
||||
});
|
||||
|
||||
it.skipIf(process.platform === "win32")(
|
||||
"stops an aborted process with the caller's signal and settles after it exits",
|
||||
async () => {
|
||||
|
|
@ -75,25 +133,48 @@ describe("execFileOutput", () => {
|
|||
),
|
||||
}),
|
||||
);
|
||||
for (let attempt = 0; attempt < 500; attempt += 1) {
|
||||
if (
|
||||
await access(ready).then(
|
||||
() => true,
|
||||
() => false,
|
||||
)
|
||||
)
|
||||
break;
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
}
|
||||
|
||||
const readyObserved = await waitForFile(ready);
|
||||
controller.abort();
|
||||
const outcome = await execution;
|
||||
|
||||
expect(readyObserved).toBe(true);
|
||||
expect(outcome?.cause).toMatchObject({ name: "AbortError" });
|
||||
expect(outcome?.stoppedBeforeSettling).toBe("SIGINT");
|
||||
},
|
||||
);
|
||||
|
||||
it.skipIf(process.platform === "win32")(
|
||||
"keeps the first stop reason when timeout arrives during abort cleanup",
|
||||
async () => {
|
||||
const directory = await createTestTempDirectory("rea-exec-double-stop-");
|
||||
const ready = join(directory, "ready");
|
||||
const script = [
|
||||
'const fs = require("node:fs");',
|
||||
'for (const name of ["SIGINT", "SIGTERM"]) process.on(name, () => {});',
|
||||
`fs.writeFileSync(${JSON.stringify(ready)}, "");`,
|
||||
"setInterval(() => {}, 1000);",
|
||||
].join("\n");
|
||||
const controller = new AbortController();
|
||||
const execution = execFileOutput(process.execPath, ["-e", script], {
|
||||
signal: controller.signal,
|
||||
timeout: 10_000,
|
||||
stopSignal: "SIGINT",
|
||||
});
|
||||
const startedAt = Date.now();
|
||||
expect(await waitForFile(ready)).toBe(true);
|
||||
const remainingBeforeDeadline = Math.max(
|
||||
0,
|
||||
9_000 - (Date.now() - startedAt),
|
||||
);
|
||||
await new Promise((resolve) =>
|
||||
setTimeout(resolve, remainingBeforeDeadline),
|
||||
);
|
||||
controller.abort();
|
||||
|
||||
await expect(execution).rejects.toMatchObject({ name: "AbortError" });
|
||||
},
|
||||
);
|
||||
|
||||
it("rejects an already aborted stoppable command without starting it", async () => {
|
||||
const controller = new AbortController();
|
||||
controller.abort();
|
||||
|
|
@ -149,6 +230,33 @@ describe.skipIf(process.platform === "win32")(
|
|||
},
|
||||
);
|
||||
|
||||
it("preserves a child failure's missing signal when reporting timeout", async () => {
|
||||
const directory = await createTestTempDirectory(
|
||||
"rea-exec-missing-signal-",
|
||||
);
|
||||
const ready = join(directory, "ready");
|
||||
const script = [
|
||||
'const fs = require("node:fs");',
|
||||
'for (const name of ["SIGINT", "SIGTERM"])',
|
||||
" process.on(name, () => process.exit(1));",
|
||||
`fs.writeFileSync(${JSON.stringify(ready)}, "");`,
|
||||
"setInterval(() => {}, 1000);",
|
||||
].join("\n");
|
||||
try {
|
||||
const execution = execFileOutput(process.execPath, ["-e", script], {
|
||||
timeout: 10_000,
|
||||
stopSignal: "SIGINT",
|
||||
});
|
||||
expect(await waitForFile(ready)).toBe(true);
|
||||
await expect(execution).rejects.toMatchObject({
|
||||
code: "ETIMEDOUT",
|
||||
signal: null,
|
||||
});
|
||||
} finally {
|
||||
await rm(directory, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("kills a timed-out process that ignores the stop signal", async () => {
|
||||
const script = [
|
||||
'for (const name of ["SIGINT", "SIGTERM"]) process.on(name, () => {});',
|
||||
|
|
@ -168,3 +276,40 @@ describe.skipIf(process.platform === "win32")(
|
|||
});
|
||||
},
|
||||
);
|
||||
|
||||
describe("execFileOutput failure metadata", () => {
|
||||
it("preserves raw byte counts on a failed command with invalid UTF-8", async () => {
|
||||
const failure: unknown = await execFileOutput(process.execPath, [
|
||||
"-e",
|
||||
"process.stdout.write(Buffer.from([0x61, 0xff])); process.stderr.write(Buffer.from([0xe2, 0x82, 0xac])); process.exitCode = 7;",
|
||||
]).then(
|
||||
() => undefined,
|
||||
(cause: unknown) => cause,
|
||||
);
|
||||
|
||||
expect(execFileOutputFailure(failure)).toMatchObject({
|
||||
stdout: "a<>",
|
||||
stderr: "€",
|
||||
stdoutBytes: 2,
|
||||
stderrBytes: 3,
|
||||
code: 7,
|
||||
});
|
||||
});
|
||||
it("preserves explicit truncation metadata from an owned process failure", () => {
|
||||
const failure = Object.assign(new Error("owned output was truncated"), {
|
||||
stdout: "partial stdout",
|
||||
stderr: "partial stderr",
|
||||
code: 7,
|
||||
outputTruncated: true,
|
||||
});
|
||||
|
||||
expect(execFileOutputFailure(failure)).toEqual({
|
||||
stdout: "partial stdout",
|
||||
stderr: "partial stderr",
|
||||
code: 7,
|
||||
signal: null,
|
||||
killed: false,
|
||||
outputTruncated: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -76,6 +76,9 @@ export interface ExecFileOutputFailure {
|
|||
readonly signal: string | null;
|
||||
readonly killed: boolean;
|
||||
readonly outputTruncated: boolean;
|
||||
readonly stdoutBytes?: number;
|
||||
readonly stderrBytes?: number;
|
||||
readonly collectionFailure?: boolean;
|
||||
}
|
||||
|
||||
/** Read captured output metadata from a failed execFileOutput invocation. */
|
||||
|
|
@ -88,15 +91,31 @@ export const execFileOutputFailure = (
|
|||
const code = Reflect.get(cause, "code");
|
||||
const signal = Reflect.get(cause, "signal");
|
||||
const killed = Reflect.get(cause, "killed");
|
||||
const outputTruncated = Reflect.get(cause, "outputTruncated");
|
||||
const stdoutBytes = Reflect.get(cause, "stdoutBytes");
|
||||
const stderrBytes = Reflect.get(cause, "stderrBytes");
|
||||
const collectionFailure = Reflect.get(cause, "collectionFailure");
|
||||
if (typeof stdout !== "string" || typeof stderr !== "string")
|
||||
return undefined;
|
||||
return {
|
||||
stdout,
|
||||
stderr,
|
||||
...(typeof stdoutBytes === "number" &&
|
||||
Number.isSafeInteger(stdoutBytes) &&
|
||||
stdoutBytes >= 0
|
||||
? { stdoutBytes }
|
||||
: {}),
|
||||
...(typeof stderrBytes === "number" &&
|
||||
Number.isSafeInteger(stderrBytes) &&
|
||||
stderrBytes >= 0
|
||||
? { stderrBytes }
|
||||
: {}),
|
||||
code: typeof code === "string" || typeof code === "number" ? code : null,
|
||||
signal: typeof signal === "string" ? signal : null,
|
||||
killed: killed === true,
|
||||
outputTruncated: code === "ERR_CHILD_PROCESS_STDIO_MAXBUFFER",
|
||||
outputTruncated:
|
||||
outputTruncated === true || code === "ERR_CHILD_PROCESS_STDIO_MAXBUFFER",
|
||||
...(collectionFailure === true ? { collectionFailure: true } : {}),
|
||||
};
|
||||
};
|
||||
|
||||
|
|
@ -150,6 +169,8 @@ export const execFileBytes = (
|
|||
if (failure !== null) {
|
||||
Reflect.set(failure, "stdout", stdout.toString("utf8"));
|
||||
Reflect.set(failure, "stderr", stderr.toString("utf8"));
|
||||
Reflect.set(failure, "stdoutBytes", stdout.byteLength);
|
||||
Reflect.set(failure, "stderrBytes", stderr.byteLength);
|
||||
reject(failure);
|
||||
return;
|
||||
}
|
||||
|
|
@ -179,8 +200,15 @@ export const execFileOutput = (
|
|||
command: string,
|
||||
arguments_: readonly string[],
|
||||
options: ExecFileOutputOptions = {},
|
||||
): Promise<{ readonly stdout: string; readonly stderr: string }> =>
|
||||
): Promise<{
|
||||
readonly stdout: string;
|
||||
readonly stderr: string;
|
||||
readonly stdoutBytes: number;
|
||||
readonly stderrBytes: number;
|
||||
}> =>
|
||||
execFileBytes(command, arguments_, options).then(({ stdout, stderr }) => ({
|
||||
stdout: stdout.toString("utf8"),
|
||||
stderr: stderr.toString("utf8"),
|
||||
stdoutBytes: stdout.byteLength,
|
||||
stderrBytes: stderr.byteLength,
|
||||
}));
|
||||
|
|
|
|||
|
|
@ -20,18 +20,23 @@ import {
|
|||
/** A short-lived command failed its lifecycle or complete-output contract. */
|
||||
export class OwnedCommandFailure extends Error {
|
||||
readonly cleanupResult: ProcessCleanupFailure | undefined;
|
||||
readonly collectionFailure: boolean;
|
||||
|
||||
constructor(
|
||||
readonly reason: "cancelled" | "timeout" | "output-limit" | "process",
|
||||
message: string,
|
||||
readonly snapshot: ProviderProcessSnapshot | null = null,
|
||||
readonly cleanupFailure: string | null = null,
|
||||
options?: ErrorOptions & { readonly cleanupResult?: ProcessCleanupFailure },
|
||||
options?: ErrorOptions & {
|
||||
readonly cleanupResult?: ProcessCleanupFailure;
|
||||
readonly collectionFailure?: boolean;
|
||||
},
|
||||
readonly resources: readonly string[] = [],
|
||||
readonly cleanupOwner?: ProviderProcessSupervisor,
|
||||
) {
|
||||
super(message, options);
|
||||
this.cleanupResult = options?.cleanupResult;
|
||||
this.collectionFailure = options?.collectionFailure ?? false;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -56,7 +61,7 @@ export const runOwnedCommand = async (
|
|||
readonly acceptNonZeroExit?: boolean;
|
||||
},
|
||||
options: {
|
||||
readonly signal?: AbortSignal;
|
||||
readonly signal?: AbortSignal | undefined;
|
||||
/** Synchronously accept unresolved cleanup ownership; execution still returns its original outcome. Must not throw. */
|
||||
readonly retainCleanup?: (owner: ProviderProcessSupervisor) => void;
|
||||
readonly launcher?: (
|
||||
|
|
@ -156,7 +161,9 @@ const collectOwnedCommand = async (
|
|||
"Command diagnostic output exceeded its complete-output budget.",
|
||||
);
|
||||
if (processFailure !== undefined)
|
||||
throw new OwnedCommandFailure("process", processFailure);
|
||||
throw new OwnedCommandFailure("process", processFailure, null, null, {
|
||||
collectionFailure: true,
|
||||
});
|
||||
await waitForAbortableDelay(25, signal);
|
||||
}
|
||||
const snapshot = supervisor.snapshot();
|
||||
|
|
@ -191,6 +198,8 @@ const collectOwnedCommand = async (
|
|||
processFailure ??
|
||||
"Command failed; inspect its exit status and diagnostics.",
|
||||
snapshot,
|
||||
null,
|
||||
{ collectionFailure: processFailure !== undefined },
|
||||
);
|
||||
} catch (cause: unknown) {
|
||||
failure =
|
||||
|
|
@ -219,6 +228,9 @@ const collectOwnedCommand = async (
|
|||
...(stopped.cleanupResult === undefined
|
||||
? {}
|
||||
: { cleanupResult: stopped.cleanupResult }),
|
||||
...(failure?.collectionFailure === true
|
||||
? { collectionFailure: true }
|
||||
: {}),
|
||||
},
|
||||
ownedCommandResources(launched.ownership, spawn.cwd),
|
||||
supervisor,
|
||||
|
|
@ -234,6 +246,7 @@ const collectOwnedCommand = async (
|
|||
...(failure.cleanupResult === undefined
|
||||
? {}
|
||||
: { cleanupResult: failure.cleanupResult }),
|
||||
...(failure.collectionFailure ? { collectionFailure: true } : {}),
|
||||
},
|
||||
failure.resources,
|
||||
);
|
||||
|
|
|
|||
|
|
@ -6,10 +6,14 @@ import { WindowsOwnedProcess } from "../windows/WindowsOwnedProcess.js";
|
|||
|
||||
import type {
|
||||
OwnedProcessGroup,
|
||||
ProcessOwnershipBaseline,
|
||||
ProcessCleanupFailure,
|
||||
ProcessCleanupResult,
|
||||
} from "./ProcessOwnership.js";
|
||||
import { prepareProcessOwnershipInspection } from "./ProcessOwnershipObservation.js";
|
||||
import {
|
||||
prepareProcessOwnershipInspection,
|
||||
systemProcessOwnershipHost,
|
||||
} from "./ProcessOwnershipObservation.js";
|
||||
|
||||
const DEFAULT_TERMINATION_GRACE_MS = 250;
|
||||
const DEFAULT_KILL_GRACE_MS = 1_000;
|
||||
|
|
@ -36,6 +40,8 @@ export interface OwnedProviderProcessSpawnOptions {
|
|||
* Windows Job Object. Other launchers cannot enforce it and refuse it.
|
||||
*/
|
||||
readonly memoryLimitBytes?: number;
|
||||
/** Sweep exact run-token descendants during POSIX cleanup. */
|
||||
readonly sweepTokenOwnedProcesses?: boolean;
|
||||
/** Cancels native ownership preparation before provider process creation. */
|
||||
readonly signal?: AbortSignal;
|
||||
}
|
||||
|
|
@ -95,6 +101,8 @@ export interface ProviderProcessSnapshot {
|
|||
readonly diagnosticTruncated?: boolean;
|
||||
readonly stdout: {
|
||||
readonly text: string;
|
||||
/** True when retained bytes cannot be represented exactly as UTF-8 text. */
|
||||
readonly invalidUtf8?: true;
|
||||
/** Bytes retained in text, limited by maxDiagnosticBytes when configured. */
|
||||
readonly bytes: number;
|
||||
/** All bytes drained from this stream since construction or resetOutput. */
|
||||
|
|
@ -102,6 +110,8 @@ export interface ProviderProcessSnapshot {
|
|||
};
|
||||
readonly stderr: {
|
||||
readonly text: string;
|
||||
/** True when retained bytes cannot be represented exactly as UTF-8 text. */
|
||||
readonly invalidUtf8?: true;
|
||||
/** Bytes retained in text, limited by maxDiagnosticBytes when configured. */
|
||||
readonly bytes: number;
|
||||
readonly observedBytes: number;
|
||||
|
|
@ -174,6 +184,16 @@ export const spawnOwnedProviderProcess = async (
|
|||
await prepareProcessOwnershipInspection(options.signal);
|
||||
options.signal?.throwIfAborted();
|
||||
const platform = options.platform ?? process.platform;
|
||||
const sweepTokenOwnedProcesses =
|
||||
options.sweepTokenOwnedProcesses === true && platform !== "win32";
|
||||
let captureBaseline: ProcessOwnershipBaseline | undefined;
|
||||
if (sweepTokenOwnedProcesses) {
|
||||
const capture = systemProcessOwnershipHost.captureBaseline;
|
||||
if (capture === undefined)
|
||||
throw new Error("Process ownership baseline is unavailable on this host");
|
||||
captureBaseline = await capture(options.signal);
|
||||
options.signal?.throwIfAborted();
|
||||
}
|
||||
const hostEnvironment = options.hostEnvironment ?? process.env;
|
||||
const environment = {
|
||||
...hostEnvironment,
|
||||
|
|
@ -226,6 +246,12 @@ export const spawnOwnedProviderProcess = async (
|
|||
leaderPid: pid,
|
||||
processGroupId: pid,
|
||||
expectedParentPid: process.pid,
|
||||
...(sweepTokenOwnedProcesses
|
||||
? {
|
||||
sweepTokenOwnedProcesses: true,
|
||||
captureBaseline: captureBaseline!,
|
||||
}
|
||||
: {}),
|
||||
...(options.expectedCommand === null
|
||||
? {}
|
||||
: { expectedCommand: options.expectedCommand ?? options.command }),
|
||||
|
|
@ -282,12 +308,14 @@ export class ProviderProcessSupervisor {
|
|||
#exitObservation: ProviderProcessExitObservation | undefined;
|
||||
#stopPromise: Promise<ProviderProcessStopResult> | undefined;
|
||||
#disposed = false;
|
||||
#maxDiagnosticBytes: number | undefined;
|
||||
|
||||
constructor(
|
||||
readonly launch: ProviderProcessLaunch,
|
||||
options: ProviderProcessSupervisorOptions = {},
|
||||
) {
|
||||
this.#options = options;
|
||||
this.#maxDiagnosticBytes = options.maxDiagnosticBytes;
|
||||
if (options.captureStdout !== false)
|
||||
this.#attach(launch.process.stdout, "stdout");
|
||||
this.#attach(launch.process.stderr, "stderr");
|
||||
|
|
@ -340,7 +368,7 @@ export class ProviderProcessSupervisor {
|
|||
);
|
||||
}
|
||||
|
||||
/** Wait until the producer has closed its output streams; exit alone does not prove complete output. */
|
||||
/** Wait until output streams close; WindowsOwnedProcess closes only after its whole Job Object settles. */
|
||||
async waitForOutputClose(timeoutMs: number): Promise<boolean> {
|
||||
return waitForProcessEvent(
|
||||
this.#outputClosed,
|
||||
|
|
@ -349,6 +377,35 @@ export class ProviderProcessSupervisor {
|
|||
);
|
||||
}
|
||||
|
||||
/** Bound additional retained bytes while still draining all later output. */
|
||||
limitAdditionalOutputBytes(maxBytes: number): void {
|
||||
if (!Number.isSafeInteger(maxBytes) || maxBytes < 0)
|
||||
throw new RangeError(
|
||||
"Additional output limit must be a non-negative safe integer.",
|
||||
);
|
||||
const retained = this.#stdout.bytes + this.#stderr.bytes;
|
||||
const requestedLimit = Math.min(
|
||||
Number.MAX_SAFE_INTEGER,
|
||||
retained + maxBytes,
|
||||
);
|
||||
this.#maxDiagnosticBytes =
|
||||
this.#maxDiagnosticBytes === undefined
|
||||
? requestedLimit
|
||||
: Math.min(this.#maxDiagnosticBytes, requestedLimit);
|
||||
}
|
||||
|
||||
/** Unreference inherited output pipes without closing streams shared with descendants. */
|
||||
unrefOutputStreams(): void {
|
||||
for (const stream of [
|
||||
this.launch.process.stdout,
|
||||
this.launch.process.stderr,
|
||||
]) {
|
||||
const unref = (stream as (Readable & { unref?: () => void }) | null)
|
||||
?.unref;
|
||||
unref?.call(stream);
|
||||
}
|
||||
}
|
||||
|
||||
/** Share each stop attempt; incomplete cleanup retains ownership and permits retry. */
|
||||
stop(
|
||||
options: ProviderProcessStopOptions = {},
|
||||
|
|
@ -509,13 +566,11 @@ export class ProviderProcessSupervisor {
|
|||
if (stream === "stdout") this.#stdoutObservedBytes += bytes.byteLength;
|
||||
else this.#stderrObservedBytes += bytes.byteLength;
|
||||
const remaining =
|
||||
this.#options.maxDiagnosticBytes === undefined
|
||||
this.#maxDiagnosticBytes === undefined
|
||||
? bytes.byteLength
|
||||
: Math.max(
|
||||
0,
|
||||
this.#options.maxDiagnosticBytes -
|
||||
this.#stdout.bytes -
|
||||
this.#stderr.bytes,
|
||||
this.#maxDiagnosticBytes - this.#stdout.bytes - this.#stderr.bytes,
|
||||
);
|
||||
const retained = Math.min(bytes.byteLength, remaining);
|
||||
if (retained < bytes.byteLength) this.#diagnosticTruncated = true;
|
||||
|
|
@ -564,11 +619,19 @@ class ProcessOutputCapture {
|
|||
}
|
||||
|
||||
snapshot(observedBytes: number): ProviderProcessSnapshot["stdout"] {
|
||||
const text = Buffer.concat(this.#chunks, this.#bytes).toString("utf8");
|
||||
const buffer = Buffer.concat(this.#chunks, this.#bytes);
|
||||
const text = buffer.toString("utf8");
|
||||
let invalidUtf8 = false;
|
||||
try {
|
||||
new TextDecoder("utf-8", { fatal: true }).decode(buffer);
|
||||
} catch {
|
||||
invalidUtf8 = true;
|
||||
}
|
||||
return {
|
||||
text,
|
||||
bytes: this.#bytes,
|
||||
observedBytes,
|
||||
...(invalidUtf8 ? { invalidUtf8: true } : {}),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,311 @@
|
|||
import { readFile } from "node:fs/promises";
|
||||
|
||||
import { parseLinuxProcessStartTime } from "./ProcessOwnershipObservation.js";
|
||||
|
||||
/** Result of terminating one debugger process owned by this application. */
|
||||
export type OwnedDebuggerStopResult =
|
||||
| { readonly status: "stopped" | "already_exited" }
|
||||
| { readonly status: "incomplete"; readonly reason: string };
|
||||
|
||||
const DETACH_TIMEOUT_MS = 1_000;
|
||||
const MAX_LATE_DEBUGGER_OUTPUT_BYTES = 64 * 1024;
|
||||
export type OwnedDebuggerDetachResult =
|
||||
| { readonly status: "no-inferior" }
|
||||
| { readonly status: "detached"; readonly outputSink: "durable" };
|
||||
export interface OwnedInferiorIdentity {
|
||||
readonly pid: string;
|
||||
readonly startTime: string;
|
||||
}
|
||||
|
||||
export type ProcessStartTimeReader = (pid: string) => Promise<string | null>;
|
||||
|
||||
const readLinuxProcessStartTime: ProcessStartTimeReader = async (pid) => {
|
||||
if (process.platform !== "linux")
|
||||
throw new Error(
|
||||
"Inferior process identity verification requires Linux /proc.",
|
||||
);
|
||||
let stat: string;
|
||||
try {
|
||||
stat = await readFile(`/proc/${pid}/stat`, "utf8");
|
||||
} catch (cause: unknown) {
|
||||
if (
|
||||
typeof cause === "object" &&
|
||||
cause !== null &&
|
||||
"code" in cause &&
|
||||
cause.code === "ENOENT"
|
||||
)
|
||||
return null;
|
||||
throw cause;
|
||||
}
|
||||
const startTime = parseLinuxProcessStartTime(stat);
|
||||
if (startTime === undefined)
|
||||
throw new Error(`Inferior PID ${pid} has an unreadable process identity.`);
|
||||
return startTime;
|
||||
};
|
||||
|
||||
/** Confirm only that every previously identified inferior process has ended. */
|
||||
export const verifyOwnedInferiorsEnded = async (
|
||||
identities: Iterable<OwnedInferiorIdentity>,
|
||||
readStartTime: ProcessStartTimeReader = readLinuxProcessStartTime,
|
||||
): Promise<boolean> => {
|
||||
let foundIdentity = false;
|
||||
for (const identity of identities) {
|
||||
foundIdentity = true;
|
||||
const observed = await readStartTime(identity.pid);
|
||||
if (observed === identity.startTime) return false;
|
||||
}
|
||||
return foundIdentity;
|
||||
};
|
||||
|
||||
const detachAttempts = new WeakMap<
|
||||
object,
|
||||
Promise<OwnedDebuggerDetachResult>
|
||||
>();
|
||||
const detachOutcomes = new WeakMap<
|
||||
object,
|
||||
"confirmed" | "unconfirmed" | "inferior-ended"
|
||||
>();
|
||||
|
||||
const isVerifiedDetachResult = (
|
||||
result: unknown,
|
||||
): result is OwnedDebuggerDetachResult =>
|
||||
typeof result === "object" &&
|
||||
result !== null &&
|
||||
(("status" in result && result.status === "no-inferior") ||
|
||||
("status" in result &&
|
||||
result.status === "detached" &&
|
||||
"outputSink" in result &&
|
||||
result.outputSink === "durable"));
|
||||
|
||||
const waitForInferiorDetach = async (
|
||||
session: object,
|
||||
detach: () => Promise<OwnedDebuggerDetachResult>,
|
||||
): Promise<OwnedDebuggerDetachResult | undefined> => {
|
||||
let attempt = detachAttempts.get(session);
|
||||
if (attempt === undefined) {
|
||||
// Providers may use `this` to access their command transport.
|
||||
const pending = Promise.resolve().then(() => detach.call(session));
|
||||
const tracked = pending.then(
|
||||
(result) => {
|
||||
detachAttempts.delete(session);
|
||||
detachOutcomes.set(
|
||||
session,
|
||||
isVerifiedDetachResult(result) ? "confirmed" : "unconfirmed",
|
||||
);
|
||||
return result;
|
||||
},
|
||||
(cause: unknown) => {
|
||||
detachAttempts.delete(session);
|
||||
detachOutcomes.set(session, "unconfirmed");
|
||||
throw cause;
|
||||
},
|
||||
);
|
||||
attempt = tracked;
|
||||
detachAttempts.set(session, tracked);
|
||||
}
|
||||
let timer!: NodeJS.Timeout;
|
||||
try {
|
||||
return await Promise.race([
|
||||
attempt,
|
||||
new Promise<undefined>((resolve) => {
|
||||
timer = setTimeout(() => resolve(undefined), DETACH_TIMEOUT_MS);
|
||||
}),
|
||||
]);
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
};
|
||||
|
||||
interface OwnedDebuggerProcess {
|
||||
readonly id: string;
|
||||
readonly exited: boolean;
|
||||
readonly inferiorStateUnknown?: () => boolean;
|
||||
readonly verifyInferiorsEndedAfterExit?: () => Promise<boolean>;
|
||||
readonly launched: {
|
||||
readonly process: { kill(signal: NodeJS.Signals): boolean };
|
||||
};
|
||||
/** Must return only after the provider verifies detachment or no inferior. */
|
||||
/** Confirms no inferior, or detaches only after verifying a durable output sink. */
|
||||
readonly detachInferior?: () => Promise<OwnedDebuggerDetachResult>;
|
||||
readonly supervisor: {
|
||||
waitForExit(timeoutMs: number): Promise<boolean>;
|
||||
/** WindowsOwnedProcess closes output only after the whole Job Object settles. */
|
||||
waitForOutputClose(timeoutMs: number): Promise<boolean>;
|
||||
unrefOutputStreams(): void;
|
||||
/** Cap bytes retained after debugger shutdown while continuing to drain. */
|
||||
limitAdditionalOutputBytes(maxBytes: number): void;
|
||||
dispose(): void;
|
||||
};
|
||||
}
|
||||
|
||||
const finishExitedDebuggerSession = async (
|
||||
session: OwnedDebuggerProcess,
|
||||
debuggerName: string,
|
||||
platform: NodeJS.Platform,
|
||||
status: "stopped" | "already_exited",
|
||||
): Promise<OwnedDebuggerStopResult> => {
|
||||
try {
|
||||
session.supervisor.limitAdditionalOutputBytes(
|
||||
MAX_LATE_DEBUGGER_OUTPUT_BYTES,
|
||||
);
|
||||
if (!(await session.supervisor.waitForOutputClose(1_000))) {
|
||||
return {
|
||||
status: "incomplete",
|
||||
reason:
|
||||
platform === "win32"
|
||||
? `Owned ${debuggerName} process leader exited, but its Windows Job Object still has active descendants or has not reported settlement.`
|
||||
: `Owned ${debuggerName} process leader exited, but inherited output streams are still open or have not reported settlement.`,
|
||||
};
|
||||
}
|
||||
} catch (cause: unknown) {
|
||||
return {
|
||||
status: "incomplete",
|
||||
reason:
|
||||
platform === "win32"
|
||||
? `Owned ${debuggerName} Windows Job Object settlement could not be verified: ${cause instanceof Error ? cause.message : String(cause)}`
|
||||
: `Owned ${debuggerName} inherited output stream settlement could not be verified: ${cause instanceof Error ? cause.message : String(cause)}`,
|
||||
};
|
||||
}
|
||||
if (platform !== "win32") session.supervisor.unrefOutputStreams();
|
||||
session.supervisor.dispose();
|
||||
return { status };
|
||||
};
|
||||
|
||||
/** Stop only the directly owned debugger process, never its process group or target. */
|
||||
export const stopOwnedDebuggerProcess = async (
|
||||
session: OwnedDebuggerProcess,
|
||||
debuggerName: string,
|
||||
options: { readonly platform?: NodeJS.Platform } = {},
|
||||
): Promise<OwnedDebuggerStopResult> => {
|
||||
const platform = options.platform ?? process.platform;
|
||||
if (session.exited) {
|
||||
if (detachAttempts.has(session))
|
||||
return {
|
||||
status: "incomplete",
|
||||
reason: `Owned ${debuggerName} inferior detach is still pending after the process exited.`,
|
||||
};
|
||||
const detachOutcome = detachOutcomes.get(session);
|
||||
if (
|
||||
detachOutcome === "unconfirmed" ||
|
||||
(detachOutcome === undefined && session.inferiorStateUnknown?.() === true)
|
||||
) {
|
||||
try {
|
||||
if ((await session.verifyInferiorsEndedAfterExit?.()) !== true)
|
||||
return {
|
||||
status: "incomplete",
|
||||
reason: `Owned ${debuggerName} inferior disposition was not verified before the debugger exited, and no owner proof confirms the identified inferior processes have ended.`,
|
||||
};
|
||||
detachOutcomes.set(session, "inferior-ended");
|
||||
} catch (cause: unknown) {
|
||||
return {
|
||||
status: "incomplete",
|
||||
reason: `Owned ${debuggerName} inferior process identity could not be verified after debugger exit: ${cause instanceof Error ? cause.message : String(cause)}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
return finishExitedDebuggerSession(
|
||||
session,
|
||||
debuggerName,
|
||||
platform,
|
||||
"already_exited",
|
||||
);
|
||||
}
|
||||
if (platform === "win32")
|
||||
return {
|
||||
status: "incomplete",
|
||||
reason: `Owned ${debuggerName} process cannot be stopped safely on Windows because job termination can also terminate its inferior processes.`,
|
||||
};
|
||||
try {
|
||||
if (session.detachInferior === undefined) {
|
||||
detachOutcomes.set(session, "unconfirmed");
|
||||
return {
|
||||
status: "incomplete",
|
||||
reason: `Owned ${debuggerName} inferior detach cannot be verified because the provider has no detach capability.`,
|
||||
};
|
||||
}
|
||||
const detached = await waitForInferiorDetach(
|
||||
session,
|
||||
session.detachInferior,
|
||||
);
|
||||
if (detached === undefined)
|
||||
return {
|
||||
status: "incomplete",
|
||||
reason: `Owned ${debuggerName} inferior detach was not verified within ${DETACH_TIMEOUT_MS} ms.`,
|
||||
};
|
||||
if (!isVerifiedDetachResult(detached)) {
|
||||
detachOutcomes.set(session, "unconfirmed");
|
||||
return {
|
||||
status: "incomplete",
|
||||
reason: `Owned ${debuggerName} provider did not confirm no inferior or detachment with a durable output sink.`,
|
||||
};
|
||||
}
|
||||
detachOutcomes.set(session, "confirmed");
|
||||
} catch (cause: unknown) {
|
||||
return {
|
||||
status: "incomplete",
|
||||
reason: `Owned ${debuggerName} inferior detach could not be verified: ${cause instanceof Error ? cause.message : String(cause)}`,
|
||||
};
|
||||
}
|
||||
session.launched.process.kill("SIGTERM");
|
||||
if (!(await session.supervisor.waitForExit(300))) {
|
||||
session.launched.process.kill("SIGKILL");
|
||||
const exitedAfterKill = await session.supervisor.waitForExit(1_000);
|
||||
if (exitedAfterKill)
|
||||
return finishExitedDebuggerSession(
|
||||
session,
|
||||
debuggerName,
|
||||
platform,
|
||||
"stopped",
|
||||
);
|
||||
return {
|
||||
status: "incomplete",
|
||||
reason: `Owned ${debuggerName} process did not exit after direct termination.`,
|
||||
};
|
||||
}
|
||||
return finishExitedDebuggerSession(
|
||||
session,
|
||||
debuggerName,
|
||||
platform,
|
||||
"stopped",
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Stop every owned session and retain failed sessions for possible recovery.
|
||||
* Callers must block replacement admission and drain in-flight starts until
|
||||
* this settles; incomplete cleanup leaves the old owner in the map for retry.
|
||||
*/
|
||||
export const closeOwnedDebuggerSessions = async <
|
||||
Session extends { id: string },
|
||||
>(
|
||||
sessions: Map<string, Session>,
|
||||
stop: (session: Session) => Promise<OwnedDebuggerStopResult>,
|
||||
debuggerName: string,
|
||||
): Promise<void> => {
|
||||
const results = await Promise.allSettled(
|
||||
[...sessions.entries()].map(async ([key, session]) => ({
|
||||
key,
|
||||
session,
|
||||
result: await stop(session),
|
||||
})),
|
||||
);
|
||||
const failures: Error[] = [];
|
||||
for (const settled of results) {
|
||||
if (settled.status === "rejected") {
|
||||
failures.push(
|
||||
settled.reason instanceof Error
|
||||
? settled.reason
|
||||
: new Error(String(settled.reason)),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
const { key, session, result } = settled.value;
|
||||
if (result.status === "incomplete") failures.push(new Error(result.reason));
|
||||
else if (sessions.get(key) === session) sessions.delete(key);
|
||||
}
|
||||
if (failures.length > 0)
|
||||
throw new AggregateError(
|
||||
failures,
|
||||
`One or more owned ${debuggerName} processes did not stop cleanly`,
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,18 @@
|
|||
import { resolve } from "node:path";
|
||||
|
||||
import type { RizinInput } from "../domain/reverseEngineering.js";
|
||||
|
||||
export const RIZIN_PROVIDER_IDENTITY = {
|
||||
id: "rizin",
|
||||
name: "Rizin",
|
||||
version: null,
|
||||
} as const;
|
||||
|
||||
/** Build a one-shot rz-pipe compatible command while retaining plugin availability. */
|
||||
export const rizinCommand = (input: RizinInput): readonly string[] => [
|
||||
"-N",
|
||||
"-q",
|
||||
"-c",
|
||||
input.command,
|
||||
resolve(input.path),
|
||||
];
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -8,6 +8,7 @@ import type { WasmArtifactService } from "../application/wasm/WasmArtifactServic
|
|||
import { wasmArtifactModule } from "../composition/wasm.js";
|
||||
import { registerWasmTools } from "./registerWasmTools.js";
|
||||
import { snapshotEnvironment } from "../process/snapshotEnvironment.js";
|
||||
import { captureProviderEnvironment } from "./providerEnvironment.js";
|
||||
import { createPeResourcesService } from "../composition/binaryDiagnostics.js";
|
||||
import type { PeResourcesService } from "../application/binaryDiagnostics/PeResourcesService.js";
|
||||
import { registerPeResourcesTool } from "./registerPeResourcesTool.js";
|
||||
|
|
@ -122,6 +123,15 @@ import {
|
|||
type ServerAnalysisSource,
|
||||
} from "./analysisAdmission.js";
|
||||
import type { WithAdmittedAnalysis } from "./analysisAdmission.js";
|
||||
import { createReverseEngineeringService } from "../composition/reverseEngineering.js";
|
||||
import { registerReverseEngineeringTools } from "./registerReverseEngineeringTools.js";
|
||||
import { CutterBridgeClient } from "../cutter/CutterBridgeClient.js";
|
||||
import { CutterBridgeService } from "../cutter/CutterBridgeService.js";
|
||||
import { registerCutterTools } from "./registerCutterTools.js";
|
||||
import { GdbSessionManager } from "../gdb/GdbSessionManager.js";
|
||||
import { registerGdbTools } from "./registerGdbTools.js";
|
||||
import { RizinDebugSessionManager } from "../rizin/RizinDebugSessionManager.js";
|
||||
import { registerRizinDebugTools } from "./registerRizinDebugTools.js";
|
||||
|
||||
const TARGET_FREE_INSTRUCTIONS =
|
||||
"REA provides reverse-engineering tools for local artifacts, native binaries, managed code, browser pages, and runtimes. Use the tool that directly answers the question; discover targets or inspect inventory only when needed. Tool results include inline Evidence and report their coverage and limitations.";
|
||||
|
|
@ -139,6 +149,7 @@ export interface CreateServerOptions {
|
|||
readonly packetCapture?: PacketCaptureService;
|
||||
readonly signalRecording?: SignalRecordingService;
|
||||
readonly wasmArtifact?: WasmArtifactService;
|
||||
readonly providerEnvironment?: Readonly<NodeJS.ProcessEnv>;
|
||||
readonly evmInterface?: EvmInterfaceService;
|
||||
readonly sqliteDatabase?: SqliteDatabaseService;
|
||||
readonly logger?: Logger;
|
||||
|
|
@ -272,6 +283,10 @@ export const createServer = (
|
|||
const session = source.kind === "session" ? source.session : undefined;
|
||||
const analysis = source.kind === "session" ? source.session : source.analysis;
|
||||
const environment = snapshotEnvironment(options.environment ?? process.env);
|
||||
const providerEnvironment = captureProviderEnvironment(
|
||||
options.providerEnvironment,
|
||||
environment,
|
||||
);
|
||||
const delivery = selectToolResultDelivery(environment, options.delivery);
|
||||
const evmInterface =
|
||||
options.evmInterface ?? evmInterfaceModule.create(environment);
|
||||
|
|
@ -292,6 +307,8 @@ export const createServer = (
|
|||
const javascriptRecovery =
|
||||
options.javascriptRecovery ?? createJavaScriptRecoveryProvider(environment);
|
||||
const artifactResources = new ArtifactResourceScope();
|
||||
const reverseEngineering =
|
||||
createReverseEngineeringService(providerEnvironment);
|
||||
const firmwareAnalysis = new FirmwareAnalysisService(
|
||||
options.firmwareAnalysis ?? createFirmwareAnalysisProvider(environment),
|
||||
);
|
||||
|
|
@ -314,6 +331,10 @@ export const createServer = (
|
|||
delivery,
|
||||
selectCompactInputSchemaPresentation(environment),
|
||||
);
|
||||
const gdbSessions = new GdbSessionManager({
|
||||
environment: providerEnvironment,
|
||||
});
|
||||
const rizinDebugSessions = new RizinDebugSessionManager(providerEnvironment);
|
||||
const android =
|
||||
options.androidAnalysis ?? createAndroidAnalysisProvider(environment);
|
||||
const pcap = options.pcapAnalysis ?? createPcapAnalysisProvider(environment);
|
||||
|
|
@ -359,7 +380,10 @@ export const createServer = (
|
|||
webNetworkCapture.close(),
|
||||
javascriptRecovery.close?.() ?? Promise.resolve(),
|
||||
artifactResources.close(),
|
||||
reverseEngineering.close(),
|
||||
firmwareAnalysis.close(),
|
||||
gdbSessions.closeAll(),
|
||||
rizinDebugSessions.closeAll(),
|
||||
])
|
||||
.then((results) => {
|
||||
const failures = results.flatMap((result) =>
|
||||
|
|
@ -405,6 +429,28 @@ export const createServer = (
|
|||
webNetworkCapture,
|
||||
};
|
||||
registerBinaryAnalysisTools(toolContext);
|
||||
registerReverseEngineeringTools(
|
||||
server,
|
||||
reverseEngineering,
|
||||
toolLogger,
|
||||
delivery,
|
||||
session === undefined
|
||||
? undefined
|
||||
: (evidence) => session.recordEvidence(evidence),
|
||||
analysisAdmission,
|
||||
session === undefined
|
||||
? undefined
|
||||
: () => session.invalidateSnapshotForExternalMutation(),
|
||||
session === undefined
|
||||
? undefined
|
||||
: (path, signal, operation, rebindFailure) =>
|
||||
session.withAdmittedExternalMutation(
|
||||
path,
|
||||
signal,
|
||||
operation,
|
||||
rebindFailure,
|
||||
),
|
||||
);
|
||||
registerFridaTools(
|
||||
server,
|
||||
fridaInstrumentation,
|
||||
|
|
@ -412,7 +458,37 @@ export const createServer = (
|
|||
delivery,
|
||||
session === undefined
|
||||
? undefined
|
||||
: (input) => session.recordEvidence(input),
|
||||
: (evidence) => session.recordEvidence(evidence),
|
||||
);
|
||||
registerCutterTools(
|
||||
server,
|
||||
new CutterBridgeService(new CutterBridgeClient(providerEnvironment)),
|
||||
toolLogger,
|
||||
delivery,
|
||||
session === undefined
|
||||
? undefined
|
||||
: (evidence) => session.recordEvidence(evidence),
|
||||
session,
|
||||
);
|
||||
registerGdbTools(
|
||||
server,
|
||||
gdbSessions,
|
||||
toolLogger,
|
||||
delivery,
|
||||
session === undefined
|
||||
? undefined
|
||||
: (evidence) => session.recordEvidence(evidence),
|
||||
session,
|
||||
);
|
||||
registerRizinDebugTools(
|
||||
server,
|
||||
rizinDebugSessions,
|
||||
toolLogger,
|
||||
delivery,
|
||||
session === undefined
|
||||
? undefined
|
||||
: (evidence) => session.recordEvidence(evidence),
|
||||
session,
|
||||
);
|
||||
const previousOnclose = server.server.onclose;
|
||||
server.server.onclose = () => {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,96 @@
|
|||
import type { BinarySessionPort } from "../application/binary/BinarySessionPort.js";
|
||||
import type { AnalysisError } from "../domain/analysisErrorBase.js";
|
||||
import { AnalysisOutputError } from "../domain/analysisErrorCore.js";
|
||||
import { projectAnalysisError } from "../domain/analysisErrorProjection.js";
|
||||
import type { Evidence } from "../domain/evidence.js";
|
||||
import { err, type Result } from "../domain/result.js";
|
||||
|
||||
export type ExternalMutationAdmission = Pick<
|
||||
BinarySessionPort,
|
||||
"withAdmittedExternalMutation"
|
||||
>;
|
||||
|
||||
/** Shared MCP admission and failure preservation for unrestricted commands. */
|
||||
export const runWithExternalMutationAdmission = async <Value>(
|
||||
session: ExternalMutationAdmission | undefined,
|
||||
operationName: string,
|
||||
signal: AbortSignal | undefined,
|
||||
operation: () => Promise<Result<Value, AnalysisError>>,
|
||||
evidenceOf: ((value: Value) => Evidence) | undefined,
|
||||
cleanupRecovery?: (
|
||||
failure: AnalysisError | undefined,
|
||||
) => Promise<Result<null, AnalysisError>>,
|
||||
retainedDetails?: (value: Value) => Readonly<Record<string, string>>,
|
||||
): Promise<Result<Value, AnalysisError>> => {
|
||||
if (session === undefined) {
|
||||
const recovered = await cleanupRecovery?.(undefined);
|
||||
if (recovered !== undefined && !recovered.ok) return recovered;
|
||||
return operation();
|
||||
}
|
||||
const admitted = await session.withAdmittedExternalMutation(
|
||||
undefined,
|
||||
signal,
|
||||
operation,
|
||||
(value, cause) =>
|
||||
value.ok
|
||||
? new AnalysisOutputError(
|
||||
operationName,
|
||||
"The external command may have changed the bound artifact, but the active analysis session could not be retired.",
|
||||
{
|
||||
cause,
|
||||
...(evidenceOf === undefined
|
||||
? {}
|
||||
: { partialObservation: evidenceOf(value.value) }),
|
||||
...(cause.cleanup === undefined
|
||||
? {}
|
||||
: { cleanup: cause.cleanup }),
|
||||
...(cause.capturedOutput === undefined
|
||||
? {}
|
||||
: { capturedOutput: cause.capturedOutput }),
|
||||
...(retainedDetails === undefined
|
||||
? {}
|
||||
: { diagnostics: retainedDetails(value.value) }),
|
||||
},
|
||||
)
|
||||
: value.error.retainSecondaryFailure(cause, {
|
||||
retirement_error: projectAnalysisError(cause),
|
||||
}),
|
||||
operationName,
|
||||
cleanupRecovery,
|
||||
);
|
||||
return admitted.ok ? admitted.value : err(admitted.error);
|
||||
};
|
||||
|
||||
/** Admit an owned close once, including when it is recovering its own blocker. */
|
||||
export const runOwnedCleanupWithExternalMutationAdmission = async <Value>(
|
||||
session: ExternalMutationAdmission | undefined,
|
||||
operationName: string,
|
||||
signal: AbortSignal | undefined,
|
||||
resourceId: string,
|
||||
cleanup: () => Promise<Result<Value, AnalysisError>>,
|
||||
): Promise<Result<Value, AnalysisError>> => {
|
||||
let completed: Result<Value, AnalysisError> | undefined;
|
||||
const cleanupOnce = async (): Promise<Result<Value, AnalysisError>> => {
|
||||
completed ??= await cleanup();
|
||||
return completed;
|
||||
};
|
||||
const admitted = await runWithExternalMutationAdmission(
|
||||
session,
|
||||
operationName,
|
||||
signal,
|
||||
cleanupOnce,
|
||||
undefined,
|
||||
async (failure) => {
|
||||
if (
|
||||
failure === undefined ||
|
||||
!failure.cleanup?.resources.includes(resourceId)
|
||||
)
|
||||
return failure === undefined
|
||||
? { ok: true, value: null }
|
||||
: { ok: false, error: failure };
|
||||
const recovered = await cleanupOnce();
|
||||
return recovered.ok ? { ok: true, value: null } : recovered;
|
||||
},
|
||||
);
|
||||
return admitted;
|
||||
};
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
import { snapshotEnvironment } from "../process/snapshotEnvironment.js";
|
||||
|
||||
/** Capture the selected provider environment at server construction time. */
|
||||
export const captureProviderEnvironment = (
|
||||
providerEnvironment: Readonly<NodeJS.ProcessEnv> | undefined,
|
||||
serverEnvironment: Readonly<NodeJS.ProcessEnv>,
|
||||
): Readonly<NodeJS.ProcessEnv> =>
|
||||
snapshotEnvironment(providerEnvironment ?? serverEnvironment);
|
||||
|
|
@ -0,0 +1,79 @@
|
|||
import type { McpServer } from "@modelcontextprotocol/server";
|
||||
import type { Logger } from "pino";
|
||||
|
||||
import type { CutterBridgeService } from "../cutter/CutterBridgeService.js";
|
||||
import type { EvidenceWriter } from "../application/investigation/InvestigationRecordPort.js";
|
||||
import { toolContract } from "../contracts/toolContracts.js";
|
||||
import { ok } from "../domain/result.js";
|
||||
import { evidenceSchema } from "../domain/evidence.js";
|
||||
import {
|
||||
runWithExternalMutationAdmission,
|
||||
type ExternalMutationAdmission,
|
||||
} from "./externalMutationAdmission.js";
|
||||
import { logToolExecution } from "./toolLogging.js";
|
||||
import { toolRegistrationOptions } from "./toolRegistrationOptions.js";
|
||||
import type { ToolResultDelivery } from "./toolResult.js";
|
||||
|
||||
/** Register Cutter discovery and command tools against their named contracts. */
|
||||
export const registerCutterTools = (
|
||||
server: McpServer,
|
||||
service: CutterBridgeService,
|
||||
logger: Logger,
|
||||
delivery: ToolResultDelivery,
|
||||
recordEvidence?: EvidenceWriter["recordEvidence"],
|
||||
session?: ExternalMutationAdmission,
|
||||
): void => {
|
||||
const list = toolContract("list_cutter_sessions");
|
||||
const command = toolContract("cutter_command");
|
||||
server.registerTool(list.name, toolRegistrationOptions(list), async () => {
|
||||
const result = await logToolExecution(logger, list.name, async () =>
|
||||
ok(await service.listSessions()),
|
||||
);
|
||||
if (!result.ok) return delivery.toCallToolResult(result, list);
|
||||
return delivery.toCallToolResult(
|
||||
{
|
||||
ok: true,
|
||||
value: { ...result.value, sessions: [...result.value.sessions] },
|
||||
},
|
||||
list,
|
||||
);
|
||||
});
|
||||
server.registerTool(
|
||||
command.name,
|
||||
toolRegistrationOptions(command),
|
||||
async (input, context) => {
|
||||
const result = await runWithExternalMutationAdmission(
|
||||
session,
|
||||
command.name,
|
||||
context.mcpReq.signal,
|
||||
() =>
|
||||
logToolExecution(logger, command.name, () =>
|
||||
service.execute(input, { signal: context.mcpReq.signal }),
|
||||
),
|
||||
(value) => value,
|
||||
() => service.retryCleanup(),
|
||||
);
|
||||
if (!result.ok) {
|
||||
const partial = evidenceSchema.safeParse(
|
||||
result.error.partialObservation,
|
||||
);
|
||||
if (partial.success) {
|
||||
const recorded = recordEvidence?.(partial.data);
|
||||
if (recorded !== undefined)
|
||||
return delivery.toRecordedPartialErrorToolResult(
|
||||
result.error,
|
||||
partial.data.evidence_id,
|
||||
recorded,
|
||||
command.returnsTargetContent,
|
||||
);
|
||||
}
|
||||
return delivery.toCallToolResult(result, command);
|
||||
}
|
||||
return delivery.toEvidenceToolResult(
|
||||
result.value,
|
||||
command,
|
||||
recordEvidence?.(result.value),
|
||||
);
|
||||
},
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,156 @@
|
|||
import type { McpServer, ServerContext } from "@modelcontextprotocol/server";
|
||||
import type { Logger } from "pino";
|
||||
|
||||
import type { GdbSessionManager } from "../gdb/GdbSessionManager.js";
|
||||
import type { EvidenceWriter } from "../application/investigation/InvestigationRecordPort.js";
|
||||
import { toolContract } from "../contracts/toolContracts.js";
|
||||
import { evidenceSchema } from "../domain/evidence.js";
|
||||
import { err } from "../domain/result.js";
|
||||
import { AnalysisInputError } from "../domain/analysisErrorCore.js";
|
||||
import {
|
||||
runOwnedCleanupWithExternalMutationAdmission,
|
||||
runWithExternalMutationAdmission,
|
||||
type ExternalMutationAdmission,
|
||||
} from "./externalMutationAdmission.js";
|
||||
import { logToolExecution } from "./toolLogging.js";
|
||||
import { toolRegistrationOptions } from "./toolRegistrationOptions.js";
|
||||
import type { ToolResultDelivery } from "./toolResult.js";
|
||||
|
||||
export type GdbToolManager = Pick<
|
||||
GdbSessionManager,
|
||||
"start" | "execute" | "status" | "close" | "retryCleanupFailures"
|
||||
>;
|
||||
|
||||
/** Register persistent GDB session operations on the existing REA MCP server. */
|
||||
export const registerGdbTools = (
|
||||
server: McpServer,
|
||||
manager: GdbToolManager,
|
||||
logger: Logger,
|
||||
delivery: ToolResultDelivery,
|
||||
recordEvidence?: EvidenceWriter["recordEvidence"],
|
||||
session?: ExternalMutationAdmission,
|
||||
): void => {
|
||||
const start = toolContract("start_gdb_session");
|
||||
const consoleContract = toolContract("gdb_console");
|
||||
const status = toolContract("gdb_session_status");
|
||||
const close = toolContract("close_gdb_session");
|
||||
server.registerTool(
|
||||
start.name,
|
||||
toolRegistrationOptions(start),
|
||||
async (input, context: ServerContext) => {
|
||||
const result = await logToolExecution(logger, start.name, () =>
|
||||
manager.start(
|
||||
context.mcpReq.signal,
|
||||
input.inferior_tty === undefined
|
||||
? {}
|
||||
: { inferiorTty: input.inferior_tty },
|
||||
),
|
||||
);
|
||||
if (result.ok)
|
||||
return delivery.toCallToolResult(
|
||||
{ ok: true, value: result.value },
|
||||
start,
|
||||
);
|
||||
const partial = result.error.partialObservation;
|
||||
const parsed = evidenceSchema.safeParse(partial);
|
||||
if (parsed.success) {
|
||||
const recorded = recordEvidence?.(parsed.data);
|
||||
if (recorded !== undefined)
|
||||
return delivery.toRecordedPartialErrorToolResult(
|
||||
result.error,
|
||||
parsed.data.evidence_id,
|
||||
recorded,
|
||||
start.returnsTargetContent,
|
||||
);
|
||||
}
|
||||
return delivery.toCallToolResult(result, start);
|
||||
},
|
||||
);
|
||||
server.registerTool(
|
||||
consoleContract.name,
|
||||
toolRegistrationOptions(consoleContract),
|
||||
async (input, context: ServerContext) => {
|
||||
const execute = () =>
|
||||
logToolExecution(logger, consoleContract.name, () =>
|
||||
manager.execute(
|
||||
input.session_id,
|
||||
input.command,
|
||||
context.mcpReq.signal,
|
||||
),
|
||||
);
|
||||
const result = await runWithExternalMutationAdmission(
|
||||
session,
|
||||
consoleContract.name,
|
||||
context.mcpReq.signal,
|
||||
execute,
|
||||
(value) => value.evidence,
|
||||
() => manager.retryCleanupFailures(),
|
||||
);
|
||||
if (!result.ok) {
|
||||
const partial = result.error.partialObservation;
|
||||
const parsed = evidenceSchema.safeParse(partial);
|
||||
if (parsed.success) {
|
||||
const recorded = recordEvidence?.(parsed.data);
|
||||
if (recorded !== undefined)
|
||||
return delivery.toRecordedPartialErrorToolResult(
|
||||
result.error,
|
||||
parsed.data.evidence_id,
|
||||
recorded,
|
||||
consoleContract.returnsTargetContent,
|
||||
);
|
||||
}
|
||||
return delivery.toCallToolResult(result, consoleContract);
|
||||
}
|
||||
const recorded = recordEvidence?.(result.value.evidence);
|
||||
return delivery.toEvidenceToolResult(
|
||||
result.value.evidence,
|
||||
consoleContract,
|
||||
recorded,
|
||||
);
|
||||
},
|
||||
);
|
||||
server.registerTool(
|
||||
status.name,
|
||||
toolRegistrationOptions(status),
|
||||
async (input) => {
|
||||
const result = manager.status(input.session_id);
|
||||
if (result === undefined)
|
||||
return delivery.toCallToolResult(
|
||||
err(invalidSession("gdb_session_status")),
|
||||
status,
|
||||
);
|
||||
return delivery.toCallToolResult(
|
||||
{
|
||||
ok: true,
|
||||
value: {
|
||||
...result,
|
||||
recent_mi_records: [...result.recent_mi_records],
|
||||
},
|
||||
},
|
||||
status,
|
||||
);
|
||||
},
|
||||
);
|
||||
server.registerTool(
|
||||
close.name,
|
||||
toolRegistrationOptions(close),
|
||||
async (input, context: ServerContext) => {
|
||||
const result = await runOwnedCleanupWithExternalMutationAdmission(
|
||||
session,
|
||||
close.name,
|
||||
context.mcpReq.signal,
|
||||
input.session_id,
|
||||
() =>
|
||||
logToolExecution(logger, close.name, () =>
|
||||
manager.close(input.session_id),
|
||||
),
|
||||
);
|
||||
return result.ok
|
||||
? delivery.toCallToolResult({ ok: true, value: result.value }, close)
|
||||
: delivery.toCallToolResult(result, close);
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const invalidSession = (operation: string): AnalysisInputError =>
|
||||
new AnalysisInputError(operation);
|
||||
|
|
@ -0,0 +1,207 @@
|
|||
import { projectAnalysisError } from "../domain/analysisErrorProjection.js";
|
||||
import type {
|
||||
CallToolResult,
|
||||
McpServer,
|
||||
ServerContext,
|
||||
} from "@modelcontextprotocol/server";
|
||||
import type { Logger } from "pino";
|
||||
|
||||
import type { ReverseEngineeringService } from "../application/reverse/ReverseEngineeringService.js";
|
||||
import type { EvidenceWriter } from "../application/investigation/InvestigationRecordPort.js";
|
||||
import type { Evidence } from "../domain/evidence.js";
|
||||
import { toolContract } from "../contracts/toolContracts.js";
|
||||
import type { ToolContract } from "../contracts/toolContractTypes.js";
|
||||
import { logToolExecution } from "./toolLogging.js";
|
||||
import { toolRegistrationOptions } from "./toolRegistrationOptions.js";
|
||||
import type { ToolResultDelivery } from "./toolResult.js";
|
||||
import type { WithAdmittedAnalysis } from "./analysisAdmission.js";
|
||||
import { rizinInputSchema } from "../domain/reverseEngineering.js";
|
||||
import { AnalysisOutputError } from "../domain/analysisErrorCore.js";
|
||||
import type { AnalysisError } from "../domain/analysisErrorBase.js";
|
||||
import type { Result } from "../domain/result.js";
|
||||
|
||||
/** Register objdump and Rizin operations against their exact named contracts. */
|
||||
export const registerReverseEngineeringTools = (
|
||||
server: McpServer,
|
||||
service: ReverseEngineeringService,
|
||||
logger: Logger,
|
||||
delivery: ToolResultDelivery,
|
||||
recordEvidence?: EvidenceWriter["recordEvidence"],
|
||||
withAdmittedAnalysis?: WithAdmittedAnalysis,
|
||||
invalidateSnapshotForExternalMutation?: () => void,
|
||||
withAdmittedExternalMutation?: <Value>(
|
||||
path: string | undefined,
|
||||
signal: AbortSignal | undefined,
|
||||
operation: () => Promise<Result<Value, AnalysisError>>,
|
||||
retirementFailure: (
|
||||
value: Result<Value, AnalysisError>,
|
||||
error: AnalysisError,
|
||||
) => AnalysisError,
|
||||
) => Promise<Result<Result<Value, AnalysisError>, AnalysisError>>,
|
||||
): void => {
|
||||
const objdump = toolContract("inspect_with_objdump");
|
||||
const rizin = toolContract("execute_rizin_command");
|
||||
server.registerTool(
|
||||
objdump.name,
|
||||
toolRegistrationOptions(objdump),
|
||||
(input, context: ServerContext) =>
|
||||
runAdmitted(
|
||||
objdump,
|
||||
context,
|
||||
withAdmittedAnalysis,
|
||||
delivery,
|
||||
async () => {
|
||||
const result = await logToolExecution(logger, objdump.name, () =>
|
||||
service.inspectWithObjdump(input, {
|
||||
signal: context.mcpReq.signal,
|
||||
}),
|
||||
);
|
||||
if (!result.ok)
|
||||
return deliverFailure(
|
||||
result.error,
|
||||
objdump,
|
||||
delivery,
|
||||
recordEvidence,
|
||||
);
|
||||
return delivery.toEvidenceToolResult(
|
||||
result.value,
|
||||
objdump,
|
||||
recordEvidence?.(result.value),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
server.registerTool(
|
||||
rizin.name,
|
||||
toolRegistrationOptions(rizin),
|
||||
async (input, context: ServerContext) => {
|
||||
const parsed = rizinInputSchema.safeParse(input);
|
||||
if (parsed.success && withAdmittedExternalMutation !== undefined) {
|
||||
const cleanup = await service.retryCleanup(rizin.name);
|
||||
if (!cleanup.ok)
|
||||
return deliverFailure(cleanup.error, rizin, delivery, recordEvidence);
|
||||
const admitted = await withAdmittedExternalMutation(
|
||||
parsed.data.path,
|
||||
context.mcpReq.signal,
|
||||
async () => {
|
||||
invalidateSnapshotForExternalMutation?.();
|
||||
return logToolExecution(logger, rizin.name, () =>
|
||||
service.executeRizinCommand(parsed.data, {
|
||||
signal: context.mcpReq.signal,
|
||||
}),
|
||||
);
|
||||
},
|
||||
(value, cause) => {
|
||||
if (!value.ok)
|
||||
return value.error.retainSecondaryFailure(cause, {
|
||||
retirement_error: projectAnalysisError(cause),
|
||||
});
|
||||
const observation = value.value;
|
||||
return new AnalysisOutputError(
|
||||
rizin.name,
|
||||
"Rizin may have changed the bound artifact, but the active analysis session could not be retired.",
|
||||
{
|
||||
cause,
|
||||
...(cause.cleanup === undefined
|
||||
? {}
|
||||
: { cleanup: cause.cleanup }),
|
||||
...(cause.capturedOutput === undefined
|
||||
? {}
|
||||
: { capturedOutput: cause.capturedOutput }),
|
||||
...(observation === undefined
|
||||
? {}
|
||||
: { partialObservation: observation }),
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
if (!admitted.ok)
|
||||
return deliverFailure(
|
||||
admitted.error,
|
||||
rizin,
|
||||
delivery,
|
||||
recordEvidence,
|
||||
);
|
||||
const result = admitted.value;
|
||||
if (!result.ok)
|
||||
return deliverFailure(result.error, rizin, delivery, recordEvidence);
|
||||
return delivery.toEvidenceToolResult(
|
||||
result.value,
|
||||
rizin,
|
||||
recordEvidence?.(result.value),
|
||||
);
|
||||
}
|
||||
return runAdmitted(
|
||||
rizin,
|
||||
context,
|
||||
withAdmittedAnalysis,
|
||||
delivery,
|
||||
async () => {
|
||||
if (
|
||||
invalidateSnapshotForExternalMutation !== undefined &&
|
||||
rizinInputSchema.safeParse(input).success
|
||||
)
|
||||
invalidateSnapshotForExternalMutation();
|
||||
const result = await logToolExecution(logger, rizin.name, () =>
|
||||
service.executeRizinCommand(input, {
|
||||
signal: context.mcpReq.signal,
|
||||
}),
|
||||
);
|
||||
if (!result.ok)
|
||||
return deliverFailure(
|
||||
result.error,
|
||||
rizin,
|
||||
delivery,
|
||||
recordEvidence,
|
||||
);
|
||||
return delivery.toEvidenceToolResult(
|
||||
result.value,
|
||||
rizin,
|
||||
recordEvidence?.(result.value),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const runAdmitted = async (
|
||||
contract: ToolContract,
|
||||
context: ServerContext,
|
||||
admission: WithAdmittedAnalysis | undefined,
|
||||
delivery: ToolResultDelivery,
|
||||
operation: () => Promise<CallToolResult>,
|
||||
): Promise<CallToolResult> => {
|
||||
if (admission === undefined) return operation();
|
||||
const admitted = await admission(
|
||||
contract.name,
|
||||
context.mcpReq.signal,
|
||||
async () => operation(),
|
||||
);
|
||||
return admitted.ok
|
||||
? admitted.value
|
||||
: delivery.toErrorToolResult(admitted.error, contract.returnsTargetContent);
|
||||
};
|
||||
|
||||
const deliverFailure = (
|
||||
error: import("../domain/analysisErrorBase.js").AnalysisError,
|
||||
contract: ToolContract,
|
||||
delivery: ToolResultDelivery,
|
||||
recordEvidence?: EvidenceWriter["recordEvidence"],
|
||||
) => {
|
||||
const partial = error.partialObservation;
|
||||
if (isEvidence(partial) && recordEvidence !== undefined)
|
||||
return delivery.toRecordedPartialErrorToolResult(
|
||||
error,
|
||||
partial.evidence_id,
|
||||
recordEvidence(partial),
|
||||
contract.returnsTargetContent,
|
||||
);
|
||||
return delivery.toErrorToolResult(error, contract.returnsTargetContent);
|
||||
};
|
||||
|
||||
const isEvidence = (value: unknown): value is Evidence =>
|
||||
typeof value === "object" &&
|
||||
value !== null &&
|
||||
"evidence_id" in value &&
|
||||
typeof value.evidence_id === "string";
|
||||
|
|
@ -0,0 +1,146 @@
|
|||
import type { McpServer, ServerContext } from "@modelcontextprotocol/server";
|
||||
import type { Logger } from "pino";
|
||||
|
||||
import type { EvidenceWriter } from "../application/investigation/InvestigationRecordPort.js";
|
||||
import { toolContract } from "../contracts/toolContracts.js";
|
||||
import { AnalysisInputError } from "../domain/analysisErrorCore.js";
|
||||
import type { RizinDebugSessionManager } from "../rizin/RizinDebugSessionManager.js";
|
||||
import { evidenceSchema } from "../domain/evidence.js";
|
||||
import {
|
||||
runOwnedCleanupWithExternalMutationAdmission,
|
||||
runWithExternalMutationAdmission,
|
||||
type ExternalMutationAdmission,
|
||||
} from "./externalMutationAdmission.js";
|
||||
import { logToolExecution } from "./toolLogging.js";
|
||||
import { toolRegistrationOptions } from "./toolRegistrationOptions.js";
|
||||
import type { ToolResultDelivery } from "./toolResult.js";
|
||||
|
||||
export type RizinDebugToolManager = Pick<
|
||||
RizinDebugSessionManager,
|
||||
"start" | "execute" | "status" | "close" | "retryCleanupFailures"
|
||||
>;
|
||||
|
||||
/** Register persistent Rizin debugger operations on the existing REA MCP server. */
|
||||
export const registerRizinDebugTools = (
|
||||
server: McpServer,
|
||||
manager: RizinDebugToolManager,
|
||||
logger: Logger,
|
||||
delivery: ToolResultDelivery,
|
||||
recordEvidence?: EvidenceWriter["recordEvidence"],
|
||||
session?: ExternalMutationAdmission,
|
||||
): void => {
|
||||
const start = toolContract("start_rizin_debug_session");
|
||||
const command = toolContract("rizin_debug_command");
|
||||
const status = toolContract("rizin_debug_session_status");
|
||||
const close = toolContract("close_rizin_debug_session");
|
||||
server.registerTool(
|
||||
start.name,
|
||||
toolRegistrationOptions(start),
|
||||
async (input, context: ServerContext) => {
|
||||
const result = await runWithExternalMutationAdmission(
|
||||
session,
|
||||
start.name,
|
||||
context.mcpReq.signal,
|
||||
() =>
|
||||
logToolExecution(logger, start.name, () =>
|
||||
manager.start(
|
||||
{
|
||||
path: input.path,
|
||||
inferior_tty: input.inferior_tty,
|
||||
...(input.backend === undefined
|
||||
? {}
|
||||
: { backend: input.backend }),
|
||||
},
|
||||
context.mcpReq.signal,
|
||||
),
|
||||
),
|
||||
undefined,
|
||||
() => manager.retryCleanupFailures(),
|
||||
(value) => ({ session_id: value.session_id }),
|
||||
);
|
||||
return result.ok
|
||||
? delivery.toCallToolResult({ ok: true, value: result.value }, start)
|
||||
: delivery.toCallToolResult(result, start);
|
||||
},
|
||||
);
|
||||
server.registerTool(
|
||||
command.name,
|
||||
toolRegistrationOptions(command),
|
||||
async (input, context: ServerContext) => {
|
||||
const result = await runWithExternalMutationAdmission(
|
||||
session,
|
||||
command.name,
|
||||
context.mcpReq.signal,
|
||||
() =>
|
||||
logToolExecution(logger, command.name, () =>
|
||||
manager.execute(
|
||||
input.session_id,
|
||||
input.command,
|
||||
context.mcpReq.signal,
|
||||
),
|
||||
),
|
||||
(value) => value.evidence,
|
||||
() => manager.retryCleanupFailures(),
|
||||
);
|
||||
if (!result.ok) {
|
||||
const partial = evidenceSchema.safeParse(
|
||||
result.error.partialObservation,
|
||||
);
|
||||
if (partial.success) {
|
||||
const recorded = recordEvidence?.(partial.data);
|
||||
if (recorded !== undefined)
|
||||
return delivery.toRecordedPartialErrorToolResult(
|
||||
result.error,
|
||||
partial.data.evidence_id,
|
||||
recorded,
|
||||
command.returnsTargetContent,
|
||||
);
|
||||
}
|
||||
return delivery.toCallToolResult(result, command);
|
||||
}
|
||||
return delivery.toEvidenceToolResult(
|
||||
result.value.evidence,
|
||||
command,
|
||||
recordEvidence?.(result.value.evidence),
|
||||
);
|
||||
},
|
||||
);
|
||||
server.registerTool(
|
||||
status.name,
|
||||
toolRegistrationOptions(status),
|
||||
async (input) => {
|
||||
const result = manager.status(input.session_id);
|
||||
if (result === undefined)
|
||||
return delivery.toCallToolResult(
|
||||
{ ok: false, error: new AnalysisInputError(status.name) },
|
||||
status,
|
||||
);
|
||||
return delivery.toCallToolResult(
|
||||
{
|
||||
ok: true,
|
||||
value: { ...result, recent_output: [...result.recent_output] },
|
||||
},
|
||||
status,
|
||||
);
|
||||
},
|
||||
);
|
||||
server.registerTool(
|
||||
close.name,
|
||||
toolRegistrationOptions(close),
|
||||
async (input, context: ServerContext) => {
|
||||
const result = await runOwnedCleanupWithExternalMutationAdmission(
|
||||
session,
|
||||
close.name,
|
||||
context.mcpReq.signal,
|
||||
input.session_id,
|
||||
() =>
|
||||
logToolExecution(logger, close.name, () =>
|
||||
manager.close(input.session_id),
|
||||
),
|
||||
);
|
||||
return result.ok
|
||||
? delivery.toCallToolResult({ ok: true, value: result.value }, close)
|
||||
: delivery.toCallToolResult(result, close);
|
||||
},
|
||||
);
|
||||
};
|
||||
|
|
@ -8,7 +8,11 @@ import {
|
|||
} from "./WindowsNativeLoader.js";
|
||||
|
||||
const fixture = (
|
||||
capabilities: { protocolStdin?: boolean; jobMemoryLimit?: unknown } = {},
|
||||
capabilities: {
|
||||
protocolStdin?: boolean;
|
||||
jobMemoryLimit?: unknown;
|
||||
privateBridgeDescriptorAccess?: unknown;
|
||||
} = {},
|
||||
) => {
|
||||
const bytes = Buffer.alloc(128);
|
||||
bytes.write("MZ");
|
||||
|
|
@ -51,6 +55,7 @@ const fixture = (
|
|||
architecture: "x64",
|
||||
filesystem: identity,
|
||||
privateDacl: true,
|
||||
privateBridgeDescriptorAccess: true,
|
||||
atomicJobAssignment: true,
|
||||
killOnOwnerClose: true,
|
||||
...capabilities,
|
||||
|
|
@ -199,4 +204,12 @@ describe("packaged Windows native admission", () => {
|
|||
expect(loaded.available).toBe(false);
|
||||
expect(loaded).not.toHaveProperty("retryable");
|
||||
});
|
||||
|
||||
it("rejects Windows controls without handle-bound Cutter descriptor access", () => {
|
||||
const value = fixture({ privateBridgeDescriptorAccess: undefined });
|
||||
expect(loadWindowsNativeAuthority(value.host)).toMatchObject({
|
||||
available: false,
|
||||
reason: expect.stringContaining("privateBridgeDescriptorAccess"),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -41,6 +41,7 @@ const inspectionSchema = z.strictObject({
|
|||
architecture: z.literal("x64"),
|
||||
filesystem: windowsFileIdentitySchema,
|
||||
privateDacl: z.literal(true),
|
||||
privateBridgeDescriptorAccess: z.literal(true),
|
||||
atomicJobAssignment: z.literal(true),
|
||||
killOnOwnerClose: z.literal(true),
|
||||
protocolStdin: z.boolean().optional(),
|
||||
|
|
|
|||
|
|
@ -59,6 +59,7 @@ const authority = (
|
|||
directory: true,
|
||||
},
|
||||
privateDacl: true,
|
||||
privateBridgeDescriptorAccess: true,
|
||||
atomicJobAssignment: true,
|
||||
killOnOwnerClose: true,
|
||||
protocolStdin: true,
|
||||
|
|
@ -105,6 +106,32 @@ const launch = (
|
|||
);
|
||||
|
||||
describe("Windows Job Object memory limits", () => {
|
||||
it("emits close only after descendants leave the Job Object", async () => {
|
||||
const { native, calls } = authority({
|
||||
polls: [
|
||||
{
|
||||
...settledPoll,
|
||||
stdoutEnded: false,
|
||||
stderrEnded: false,
|
||||
activeProcesses: 1,
|
||||
},
|
||||
settledPoll,
|
||||
],
|
||||
});
|
||||
const child = launch(native);
|
||||
const events: string[] = [];
|
||||
child.once("exit", () => events.push("exit"));
|
||||
child.once("close", () => events.push("close"));
|
||||
|
||||
await once(child, "close");
|
||||
|
||||
expect(events).toEqual(["exit", "close"]);
|
||||
expect(
|
||||
calls.filter(({ operation }) => operation === "process_poll"),
|
||||
).toHaveLength(2);
|
||||
expect(calls.at(-1)?.operation).toBe("process_close");
|
||||
});
|
||||
|
||||
it("refuses a requested limit before launch when the artifact lacks the control", () => {
|
||||
const { native, calls } = authority();
|
||||
expect(() => launch(native, LIMIT)).toThrow(
|
||||
|
|
|
|||
|
|
@ -0,0 +1,235 @@
|
|||
import { mutationRanges } from "./scripts/lib/reverse-tools-mutation-targets.mjs";
|
||||
|
||||
const config = {
|
||||
$schema: "./node_modules/@stryker-mutator/core/schema/stryker-schema.json",
|
||||
testRunner: "vitest",
|
||||
plugins: [
|
||||
"@stryker-mutator/typescript-checker",
|
||||
"@stryker-mutator/vitest-runner",
|
||||
],
|
||||
vitest: {
|
||||
configFile: "vitest.reverse-tools.config.ts",
|
||||
related: true,
|
||||
},
|
||||
checkers: ["typescript"],
|
||||
tsconfigFile: "tsconfig.reverse-tools-mutation.json",
|
||||
typescriptChecker: {
|
||||
prioritizePerformanceOverAccuracy: true,
|
||||
},
|
||||
coverageAnalysis: "perTest",
|
||||
thresholds: { break: null },
|
||||
ignorePatterns: ["/.cache/rea-command-locks/**", "/coverage/**"],
|
||||
concurrency: 2,
|
||||
timeoutMS: 30000,
|
||||
reporters: ["clear-text", "progress", "html", "json"],
|
||||
};
|
||||
|
||||
export default {
|
||||
...config,
|
||||
mutate: [
|
||||
...mutationRanges("src/application/reverse/ReverseEngineeringService.ts", [
|
||||
{
|
||||
kind: "method",
|
||||
owner: "ReverseEngineeringService",
|
||||
name: "inspectWithObjdump",
|
||||
},
|
||||
{
|
||||
kind: "method",
|
||||
owner: "ReverseEngineeringService",
|
||||
name: "executeRizinCommand",
|
||||
},
|
||||
{
|
||||
kind: "method",
|
||||
owner: "ReverseEngineeringService",
|
||||
name: "#observe",
|
||||
},
|
||||
{
|
||||
kind: "method",
|
||||
owner: "ReverseEngineeringService",
|
||||
name: "retryCleanup",
|
||||
},
|
||||
{
|
||||
kind: "guard",
|
||||
owner: "ReverseEngineeringService",
|
||||
method: "#observeProvider",
|
||||
condition: "signal?.aborted",
|
||||
direct: true,
|
||||
},
|
||||
{
|
||||
kind: "guard",
|
||||
owner: "ReverseEngineeringService",
|
||||
method: "#observeProvider",
|
||||
condition: "!cleanup.ok",
|
||||
},
|
||||
{
|
||||
kind: "declaration",
|
||||
owner: "ReverseEngineeringService",
|
||||
method: "#observeProvider",
|
||||
name: "absolutePath",
|
||||
},
|
||||
{
|
||||
kind: "declaration",
|
||||
owner: "ReverseEngineeringService",
|
||||
method: "#observeProvider",
|
||||
name: "cleanup",
|
||||
},
|
||||
{
|
||||
kind: "try",
|
||||
owner: "ReverseEngineeringService",
|
||||
method: "#observeProvider",
|
||||
assignment: "digest",
|
||||
},
|
||||
{
|
||||
kind: "try",
|
||||
owner: "ReverseEngineeringService",
|
||||
method: "#observeProvider",
|
||||
callee: "this.#run",
|
||||
},
|
||||
{
|
||||
kind: "expression",
|
||||
text: "snapshot.signal !== null && snapshot.signal !== undefined",
|
||||
},
|
||||
]),
|
||||
...mutationRanges("src/process/ExecFileOutput.ts", [
|
||||
{
|
||||
kind: "declaration",
|
||||
name: "execFileOutputFailure",
|
||||
},
|
||||
]),
|
||||
...mutationRanges("src/server/providerEnvironment.ts", [
|
||||
{
|
||||
kind: "declaration",
|
||||
name: "captureProviderEnvironment",
|
||||
},
|
||||
]),
|
||||
...mutationRanges("src/server/registerReverseEngineeringTools.ts", [
|
||||
{
|
||||
kind: "declaration",
|
||||
name: "registerReverseEngineeringTools",
|
||||
},
|
||||
]),
|
||||
...mutationRanges("src/gdb/GdbSessionManager.ts", [
|
||||
{
|
||||
kind: "constructor",
|
||||
owner: "GdbCommandTimeoutError",
|
||||
},
|
||||
{
|
||||
kind: "constructor",
|
||||
owner: "GdbCommandProcessExitError",
|
||||
},
|
||||
{
|
||||
kind: "constructor",
|
||||
owner: "GdbSessionManager",
|
||||
},
|
||||
{
|
||||
kind: "declaration",
|
||||
name: "DEFAULT_MAX_SESSIONS",
|
||||
},
|
||||
{
|
||||
kind: "declaration",
|
||||
owner: "GdbSessionManager",
|
||||
method: "#startProtocol",
|
||||
name: "session",
|
||||
},
|
||||
{
|
||||
kind: "declaration",
|
||||
owner: "GdbSessionManager",
|
||||
method: "#startProtocol",
|
||||
name: "stdoutSettled",
|
||||
},
|
||||
{
|
||||
kind: "call",
|
||||
owner: "GdbSessionManager",
|
||||
method: "#startProtocol",
|
||||
callee: "launched.process.stdout?.on",
|
||||
event: "data",
|
||||
},
|
||||
{
|
||||
kind: "call",
|
||||
owner: "GdbSessionManager",
|
||||
method: "#startProtocol",
|
||||
callee: "launched.process.stdin?.on",
|
||||
event: "error",
|
||||
},
|
||||
{
|
||||
kind: "call",
|
||||
owner: "GdbSessionManager",
|
||||
method: "#startProtocol",
|
||||
callee: "launched.process.stdin?.once",
|
||||
event: "close",
|
||||
},
|
||||
{
|
||||
kind: "call",
|
||||
owner: "GdbSessionManager",
|
||||
method: "#startProtocol",
|
||||
callee: "launched.process.once",
|
||||
event: "exit",
|
||||
},
|
||||
{
|
||||
kind: "guard",
|
||||
owner: "GdbSessionManager",
|
||||
method: "#startProtocol",
|
||||
condition: "launched.process.stdout === null",
|
||||
},
|
||||
{
|
||||
kind: "method",
|
||||
owner: "GdbSessionManager",
|
||||
name: "close",
|
||||
},
|
||||
]),
|
||||
...mutationRanges("src/process/ownedDebuggerSession.ts", [
|
||||
{
|
||||
kind: "declaration",
|
||||
name: "waitForInferiorDetach",
|
||||
},
|
||||
]),
|
||||
...mutationRanges("src/rizin/RizinDebugSessionManager.ts", [
|
||||
{
|
||||
kind: "guard",
|
||||
owner: "RizinDebugSessionManager",
|
||||
method: "execute",
|
||||
condition: "captured.outputTruncated",
|
||||
},
|
||||
{
|
||||
kind: "declaration",
|
||||
owner: "RizinDebugSessionManager",
|
||||
method: "execute",
|
||||
name: "result",
|
||||
withinBlockWithBinding: "captured",
|
||||
},
|
||||
{
|
||||
kind: "declaration",
|
||||
owner: "RizinDebugSessionManager",
|
||||
method: "execute",
|
||||
name: "evidence",
|
||||
withinBlockWithBinding: "captured",
|
||||
},
|
||||
{
|
||||
kind: "declaration",
|
||||
owner: "RizinDebugSessionManager",
|
||||
method: "#receive",
|
||||
name: "nextOffset",
|
||||
},
|
||||
]),
|
||||
...mutationRanges("src/cli/reverseEngineeringCommands.ts", [
|
||||
{
|
||||
kind: "command-handler",
|
||||
command: "debugWithGdb",
|
||||
},
|
||||
{
|
||||
kind: "command-handler",
|
||||
command: "debugWithRizin",
|
||||
},
|
||||
]),
|
||||
...mutationRanges("src/cutter/CutterBridgeClient.ts", [
|
||||
{
|
||||
kind: "expression",
|
||||
text: "status.data.current_file === undefined",
|
||||
},
|
||||
{
|
||||
kind: "expression",
|
||||
text: "response.current_file === undefined",
|
||||
},
|
||||
]),
|
||||
],
|
||||
};
|
||||
|
|
@ -0,0 +1,51 @@
|
|||
import { mutationRanges } from "./scripts/lib/reverse-tools-mutation-targets.mjs";
|
||||
|
||||
const config = {
|
||||
$schema: "./node_modules/@stryker-mutator/core/schema/stryker-schema.json",
|
||||
testRunner: "command",
|
||||
commandRunner: {
|
||||
command: "npx vitest run --config vitest.reverse-tools-semantics.config.ts",
|
||||
},
|
||||
plugins: ["@stryker-mutator/typescript-checker"],
|
||||
checkers: ["typescript"],
|
||||
tsconfigFile: "tsconfig.reverse-tools-mutation.json",
|
||||
typescriptChecker: {
|
||||
prioritizePerformanceOverAccuracy: true,
|
||||
},
|
||||
coverageAnalysis: "off",
|
||||
thresholds: { break: null },
|
||||
ignorePatterns: ["/.cache/rea-command-locks/**", "/coverage/**"],
|
||||
concurrency: 2,
|
||||
timeoutMS: 30000,
|
||||
reporters: ["clear-text", "progress", "html", "json"],
|
||||
};
|
||||
|
||||
export default {
|
||||
...config,
|
||||
mutate: [
|
||||
...mutationRanges("src/cutter/CutterBridgeClient.ts", [
|
||||
{
|
||||
kind: "expression",
|
||||
text: "status.data.current_file === undefined",
|
||||
},
|
||||
{
|
||||
kind: "expression",
|
||||
text: "response.current_file === undefined",
|
||||
},
|
||||
]),
|
||||
...mutationRanges("src/rizin/RizinDebugSessionManager.ts", [
|
||||
{
|
||||
kind: "declaration",
|
||||
name: "isRizinCommandDeadlineExpired",
|
||||
},
|
||||
{
|
||||
kind: "declaration",
|
||||
name: "createRizinCommandTimeoutError",
|
||||
},
|
||||
{
|
||||
kind: "declaration",
|
||||
name: "createRizinCommandTimeoutEvidence",
|
||||
},
|
||||
]),
|
||||
],
|
||||
};
|
||||
|
|
@ -0,0 +1,183 @@
|
|||
import { createServer } from "node:net";
|
||||
import { createHmac } from "node:crypto";
|
||||
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { Cli } from "incur";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
|
||||
import { createCli } from "../../../src/cli.js";
|
||||
import { serveCli } from "./cliServeHarness.js";
|
||||
|
||||
const SESSION_ID = "27d3e3f1-f1e5-49ae-91ec-95af1f343a5a";
|
||||
const registeredCommands = Cli.toCommands as WeakMap<
|
||||
object,
|
||||
Map<string, unknown>
|
||||
>;
|
||||
|
||||
const cutterCommand = async (
|
||||
environment: Record<string, string | undefined>,
|
||||
args: {
|
||||
readonly sessionId: string;
|
||||
readonly expectedGeneration: number;
|
||||
readonly command: string;
|
||||
},
|
||||
): Promise<unknown> => {
|
||||
const cli = createCli(environment);
|
||||
const entry = registeredCommands.get(cli)?.get("cutter-command") ?? {};
|
||||
const run = Reflect.get(entry, "run") as (...args: unknown[]) => unknown;
|
||||
return Reflect.apply(run, undefined, [{ args, options: { json: false } }]);
|
||||
};
|
||||
|
||||
const listen = async (
|
||||
server: ReturnType<typeof createServer>,
|
||||
): Promise<number> => {
|
||||
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
|
||||
const address = server.address();
|
||||
if (address === null || typeof address === "string")
|
||||
throw new Error("Expected an ephemeral TCP listener");
|
||||
return address.port;
|
||||
};
|
||||
|
||||
describe("Cutter CLI commands", () => {
|
||||
let directory: string | undefined;
|
||||
let server: ReturnType<typeof createServer> | undefined;
|
||||
|
||||
afterEach(async () => {
|
||||
if (server !== undefined)
|
||||
await new Promise<void>((resolve) => server?.close(() => resolve()));
|
||||
if (directory !== undefined)
|
||||
await rm(directory, { recursive: true, force: true });
|
||||
directory = undefined;
|
||||
server = undefined;
|
||||
});
|
||||
|
||||
it("lists sessions from the selected bridge directory", async () => {
|
||||
directory = await mkdtemp(join(tmpdir(), "rea-cutter-cli-test-"));
|
||||
|
||||
const result = await serveCli(["list-cutter-sessions", "--json"], {
|
||||
REA_CUTTER_BRIDGE_DIR: directory,
|
||||
});
|
||||
|
||||
expect(result.exitCode).toBe(0);
|
||||
expect(JSON.parse(result.stdout)).toMatchObject({
|
||||
sessions: [],
|
||||
discovery_status: "no_live_bridge_found",
|
||||
bridge_directory: directory,
|
||||
});
|
||||
});
|
||||
|
||||
it("reports an unavailable selected session for a command", async () => {
|
||||
directory = await mkdtemp(join(tmpdir(), "rea-cutter-cli-test-"));
|
||||
|
||||
const result = await serveCli(
|
||||
["cutter-command", SESSION_ID, "0", "pdi", "--json"],
|
||||
{ REA_CUTTER_BRIDGE_DIR: directory },
|
||||
);
|
||||
|
||||
expect(result.exitCode).toBe(0);
|
||||
expect(result.stdout).toContain("invalid_request");
|
||||
});
|
||||
|
||||
it("returns Evidence when the selected Cutter session completes a command", async () => {
|
||||
directory = await mkdtemp(join(tmpdir(), "rea-cutter-cli-test-"));
|
||||
const token = "synthetic-cutter-cli-token-for-test";
|
||||
let nextConnectionOrdinal = 0;
|
||||
server = createServer((connection) => {
|
||||
const ordinal = ++nextConnectionOrdinal;
|
||||
connection.setEncoding("utf8");
|
||||
let input = "";
|
||||
let clientNonce = "";
|
||||
let serverNonce = "";
|
||||
connection.on("data", (chunk: string) => {
|
||||
input += chunk;
|
||||
const newline = input.indexOf("\n");
|
||||
if (newline < 0) return;
|
||||
const frame = JSON.parse(input.slice(0, newline)) as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
input = input.slice(newline + 1);
|
||||
if (frame.kind === "rea-auth-v2") {
|
||||
clientNonce = String(frame.nonce);
|
||||
serverNonce = `${ordinal.toString(16).padStart(16, "0")}${"b".repeat(48)}`;
|
||||
connection.write(
|
||||
`${JSON.stringify({ session_id: SESSION_ID, nonce: serverNonce, proof: createHmac("sha256", token).update(`rea-cutter-server-v2:${SESSION_ID}:${clientNonce}:${serverNonce}`).digest("hex") })}\n`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
const requestBytes = String(frame.request);
|
||||
const request = JSON.parse(requestBytes) as Record<string, unknown>;
|
||||
if (
|
||||
frame.proof !==
|
||||
createHmac("sha256", token)
|
||||
.update(
|
||||
`rea-cutter-client-v2:${SESSION_ID}:${clientNonce}:${serverNonce}:${requestBytes}`,
|
||||
)
|
||||
.digest("hex")
|
||||
) {
|
||||
connection.destroy();
|
||||
return;
|
||||
}
|
||||
const status = request.kind === "status";
|
||||
const response = JSON.stringify({
|
||||
ok: true,
|
||||
session_id: request.session_id,
|
||||
...(status
|
||||
? {
|
||||
current_file: null,
|
||||
document_generation: 0,
|
||||
cutter_version: "Cutter fixture",
|
||||
identity_status: "partial",
|
||||
completed_connection: (ordinal - 1)
|
||||
.toString(16)
|
||||
.padStart(16, "0"),
|
||||
completed_commands: [],
|
||||
}
|
||||
: request.kind === "ack"
|
||||
? { acknowledged: false }
|
||||
: {
|
||||
output: { answer: 42 },
|
||||
current_file: null,
|
||||
document_generation: 0,
|
||||
cutter_version: "Cutter fixture",
|
||||
identity_status: "partial",
|
||||
execution_state: "complete",
|
||||
output_truncated: false,
|
||||
}),
|
||||
});
|
||||
const proof = createHmac("sha256", token)
|
||||
.update(
|
||||
`rea-cutter-response-v2:${SESSION_ID}:${clientNonce}:${serverNonce}:${response}`,
|
||||
)
|
||||
.digest("hex");
|
||||
connection.end(`${JSON.stringify({ response, proof })}\n`);
|
||||
});
|
||||
});
|
||||
const port = await listen(server);
|
||||
await writeFile(
|
||||
join(directory, `cutter-${process.pid}-${SESSION_ID}.json`),
|
||||
JSON.stringify({
|
||||
session_id: SESSION_ID,
|
||||
pid: process.pid,
|
||||
host: "127.0.0.1",
|
||||
port,
|
||||
token,
|
||||
document_generation: 0,
|
||||
current_file: null,
|
||||
cutter_version: "Cutter fixture",
|
||||
identity_status: "partial",
|
||||
}),
|
||||
{ mode: 0o600 },
|
||||
);
|
||||
|
||||
const result = await cutterCommand(
|
||||
{ REA_CUTTER_BRIDGE_DIR: directory },
|
||||
{ sessionId: SESSION_ID, expectedGeneration: 0, command: "pdi" },
|
||||
);
|
||||
|
||||
expect(JSON.stringify(result)).toContain("rea.cutter.command-observation");
|
||||
expect(JSON.stringify(result)).toContain('"answer":42');
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue