Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/scripts/bump_revengai.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@
PYPROJECT = Path(__file__).resolve().parents[2] / "pyproject.toml"
PYPI_URL = "https://pypi.org/pypi/revengai/json"

SDK_PIN_RE = re.compile(r'"revengai>=([\d.]+)"')
SDK_PIN_RE = re.compile(r'"revengai>=([\w.]+)"')


def fetch_latest_pypi_version() -> str:
Expand Down
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,8 @@ The builds for latest stable version of the RevEng.AI IDA plugin can be download

### Supported IDA versions

We support the following IDA versions and platforms: `8.5`, `9.0`, `9.1`, `9.2` and Python version from `3.10` to `3.14`.
We support the following IDA versions and platforms: `9.0`, `9.1`, `9.2`, `9.3`, `9.4`, `9.5`
and Python version from `3.10` to `3.14`. CI runs the suite against one build of each.

### Requirements
The package `PyQt5` must be installed.
Expand Down
2 changes: 1 addition & 1 deletion ida-plugin.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
"entryPoint": "reai_toolkit_entry.py",
"categories": ["decompilation", "integration-with-third-parties-interoperability", "malware-analysis", "other"],
"logoPath": "reai_toolkit/app/components/resources/reveng_ai_logo.jpg",
"idaVersions": ">=8.5",
"idaVersions": ">=9.0",
"description": "The RevEng.AI plugin integrates AI-powered code analysis and decompilation features into IDA Pro, enhancing reverse engineering workflows with advanced machine learning capabilities.",
"version": "3.0.0"
}
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ dependencies = [
"requests>=2.32",
"loguru>=0.7",
"pydantic",
"revengai>=4.72.2",
"revengai>=4.74.4rc1",
"libbs>=3.8.1",
]

Expand Down
10 changes: 10 additions & 0 deletions reai_toolkit/app/core/shared_schema.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
from enum import Enum
from typing import Generic, List, Optional, TypeVar

from pydantic import BaseModel
Expand Down Expand Up @@ -43,3 +44,12 @@ class FunctionBoundary(BaseModel):
class Symbols(BaseModel):
base_address: int
function_boundaries: List[FunctionBoundary]


class TaskStatus(str, Enum):
UNINITIALISED = "UNINITIALISED"
PENDING = "PENDING"
RUNNING = "RUNNING"
COMPLETED = "COMPLETED"
FAILED = "FAILED"
UNKNOWN_DEFAULT_OPEN_API = "unknown_default_open_api"
7 changes: 7 additions & 0 deletions reai_toolkit/app/interfaces/base_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,13 @@ def update_function_name(ea: int, new_name: str, check_user_flags: bool = False)
# If is_uname isn't available/behaves oddly, treat unknown as not user
pass

# IDA 9.5 accepts non-identifier names through SN_CHECK where 9.3 and
# earlier rejected them, so validity is asserted here instead. Without
# this the caller never learns the name needs canonicalising and writes
# it into the IDB verbatim.
if not ida_name.is_ident(new_name):
return False

# Prefer setting sync names as AUTO so users can easily override them later.
# https://python.docs.hex-rays.com/ida_name/index.html#ida_name.set_name
# SN_CHECK: check for validity
Expand Down
3 changes: 1 addition & 2 deletions reai_toolkit/app/services/ai_decomp/ai_decomp_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,14 +17,13 @@
from revengai.models.get_tokens_response import GetTokensResponse
from revengai.models.patch_comment_body import PatchCommentBody
from revengai.models.summary_data import SummaryData
from revengai.models.task_status import TaskStatus
from revengai.models.token import Token
from revengai.models.upsert_overrides_input_body import UpsertOverridesInputBody
from revengai.models.upsert_rating_input_body import UpsertRatingInputBody
from revengai.models.workflow_progress import WorkflowProgress

from reai_toolkit.app.core.netstore_service import SimpleNetStore
from reai_toolkit.app.core.shared_schema import GenericApiReturn
from reai_toolkit.app.core.shared_schema import GenericApiReturn, TaskStatus
from reai_toolkit.app.core.utils import parse_exception
from reai_toolkit.app.interfaces.thread_service import IThreadService
from reai_toolkit.app.services.ai_decomp.attribution import (
Expand Down
18 changes: 18 additions & 0 deletions reai_toolkit/app/services/auth/auth_service.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import json
from typing import Tuple

from loguru import logger
from pydantic import ValidationError
from revengai import ApiClient, ApiException, ConfigApi, Configuration, IAMUsersApi, User

from reai_toolkit.app.core.config_service import ConfigService
Expand Down Expand Up @@ -143,12 +145,28 @@ def get_me(self, force_refresh: bool = False) -> User | None:
api_client.user_agent = self.sdk_config.user_agent
try:
self._user = IAMUsersApi(api_client).get_me()
except ValidationError:
self._user = self._get_me_unvalidated(api_client)
except Exception as e:
logger.error(f"RevEng.AI: Failed to fetch current user: {e}")
self._user = None

return self._user

@staticmethod
def _get_me_unvalidated(api_client: ApiClient) -> User | None:
try:
response = IAMUsersApi(api_client).get_me_without_preload_content()
if response.status != 200:
logger.error(
f"RevEng.AI: Failed to fetch current user: HTTP {response.status}"
)
return None
return User.model_construct(**json.loads(response.data))
except Exception as e:
logger.error(f"RevEng.AI: Failed to fetch current user: {e}")
return None

def is_enthusiast(self) -> bool:
"""
Return whether the authenticated user is on the Enthusiast tier.
Expand Down
1 change: 1 addition & 0 deletions reai_toolkit/app/services/chat/schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
17: "QUESTION_ASKED",
18: "USER_ANSWERED",
19: "TOOL_CALL_PROGRESS",
20: "AGENT_RUN_UPDATE",
}

TERMINAL_EVENTS: frozenset[str] = frozenset(
Expand Down
3 changes: 1 addition & 2 deletions reai_toolkit/app/services/matching/matching_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,11 +22,10 @@
MatchFilters,
ProgressMessage,
StartMatchingForFunctionsInputBody,
TaskStatus,
)

from reai_toolkit.app.core import SimpleNetStore
from reai_toolkit.app.core.shared_schema import GenericApiReturn
from reai_toolkit.app.core.shared_schema import GenericApiReturn, TaskStatus
from reai_toolkit.app.interfaces.thread_service import IThreadService
from reai_toolkit.app.services.matching.schema import (
BatchDoneEvent,
Expand Down
2 changes: 1 addition & 1 deletion tests/idalib/ai_decomp/test_smoke.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,10 @@
from revengai.models.decompilation_data import DecompilationData
from revengai.models.inline_comment import InlineComment
from revengai.models.summary_data import SummaryData
from revengai.models.task_status import TaskStatus
from revengai.models.get_tokens_response import GetTokensResponse
from revengai.models.rendered_token import RenderedToken

from reai_toolkit.app.core.shared_schema import TaskStatus
from reai_toolkit.app.services.ai_decomp import ai_decomp_service as svc_mod
from reai_toolkit.app.services.ai_decomp.ai_decomp_service import AiDecompService

Expand Down
8 changes: 8 additions & 0 deletions tests/idalib/core/test_base_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,14 @@ def test_update_function_name_idempotent_when_unchanged(add_func):
assert BaseService.update_function_name(add_func, "reai_same") is True


def test_update_function_name_rejects_non_identifier_names(add_func):
before = ida_name.get_name(add_func)

assert BaseService.update_function_name(add_func, "bad::name!!") is False
assert BaseService.update_function_name(add_func, "bad name") is False
assert ida_name.get_name(add_func) == before


def test_update_function_name_respects_user_names(add_func):
ida_name.set_name(add_func, "user_picked", ida_name.SN_CHECK)

Expand Down
2 changes: 2 additions & 0 deletions tests/ui/ai_decomp_ui_runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -342,6 +342,7 @@ def repaint(target=None):
report["diag_expected"] = expected
report["diag_painted"] = probe.painted

report["rendering_hook_delivered"] = probe.calls > 0
report["rendering_hook_runs_on_repaint"] = probe.calls > 0
report["rendering_hook_sees_the_disassembly"] = bool(probe.widget_types) and set(
probe.widget_types
Expand Down Expand Up @@ -389,6 +390,7 @@ def main() -> None:
"a_line_with_no_counterpart_lights_nothing": False,
"disassembly_ea_lights_its_decomp_line": False,
"an_unattributed_address_lights_nothing": False,
"rendering_hook_delivered": False,
"rendering_hook_runs_on_repaint": False,
"rendering_hook_sees_the_disassembly": False,
"attributed_addresses_paint": False,
Expand Down
2 changes: 1 addition & 1 deletion tests/ui/chat_ui_runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ def pump_until(predicate, timeout: float = 10.0) -> bool:
return predicate()

panel = ChatPanel(on_closed=lambda: None)
panel.Create(ChatPanel.TITLE)
report["create_returned"] = panel.Create(ChatPanel.TITLE)
pump()

if panel._transcript is None or panel._input is None:
Expand Down
49 changes: 46 additions & 3 deletions tests/ui/test_gui_ai_decomp.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,15 @@
RUNNER = os.path.join(os.path.dirname(os.path.abspath(__file__)), "ai_decomp_ui_runner.py")
HELLO_ELF = os.path.join(ROOT, "tests", "fixtures", "hello.elf")

# IDA 9.0/9.1 never invoke get_lines_rendering_info under -A, so these only
# apply where the host delivers the callback at all (see rendering_hook_delivered).
REPAINT_CHECKS = {
"rendering_hook_runs_on_repaint",
"rendering_hook_sees_the_disassembly",
"attributed_addresses_paint",
"unattributed_lines_are_left_alone",
}

CHECKS = [
"view_created",
"editor_read_only",
Expand Down Expand Up @@ -64,6 +73,32 @@ def _headless_env() -> dict[str, str]:
return {"QT_QPA_PLATFORM": "offscreen"}
return {}

def _isolated_idausr(tmp_path) -> dict[str, str]:
# conftest points IDAUSR at one directory shared with the idalib session in
# this process. The GUI IDA spawned below must not share it: with the same
# IDAUSR it starts but never creates its windows, so OnCreate never runs.
usr = tmp_path / "idausr"
usr.mkdir(exist_ok=True)
reg = os.path.join(os.environ.get("IDADIR", ""), "ida.reg")
if os.path.isfile(reg):
shutil.copy(reg, usr / "ida.reg")
return {"IDAUSR": str(usr)}

def _ida_path_without_venv(env: dict[str, str]) -> dict[str, str]:
# IDA's embedded Python resolves its site-packages from the first python3
# on PATH. Under `uv run` that is the project venv, which carries PySide6 as
# a dev dependency, so IDA 9.0/9.1 load Qt6 bindings into a Qt5 application
# and PluginForm never builds its widgets. Drop the venv so IDA uses the Qt
# binding it ships with.
venv = env.get("VIRTUAL_ENV")
if not venv:
return env
env = dict(env)
env["PATH"] = os.pathsep.join(
p for p in env.get("PATH", "").split(os.pathsep) if p and not p.startswith(venv)
)
return env


@pytest.mark.skipif(
os.environ.get("REAI_UI_TESTS") != "1",
Expand All @@ -82,12 +117,13 @@ def test_ai_decomp_editing_flow_in_gui(tmp_path):
shutil.copy(HELLO_ELF, binary)
report_path = tmp_path / "report.json"
log_path = tmp_path / "ida.log"
env = dict(
env = _ida_path_without_venv(dict(
os.environ,
REAI_UI_REPORT=str(report_path),
REAI_UI_ROOT=ROOT,
**_headless_env(),
)
**_isolated_idausr(tmp_path),
))

proc = subprocess.run(
[ida, "-A", f"-S{RUNNER}", f"-L{log_path}", str(binary)],
Expand All @@ -111,7 +147,14 @@ def test_ai_decomp_editing_flow_in_gui(tmp_path):

report = json.loads(report_path.read_text())

assert report["errors"] == [], report["errors"]
assert report["errors"] == [], (
f"{report['errors']}"
f"\nreport: {report}"
f"\nida log tail:\n{log_path.read_text()[-3000:] if log_path.is_file() else '(no log)'}"
)
assert report["ok"] is True
delivered = report.get("rendering_hook_delivered", False)
for check in CHECKS:
if check in REPAINT_CHECKS and not delivered:
continue
assert report.get(check) is True, f"{check} failed: {report}"
37 changes: 34 additions & 3 deletions tests/ui/test_gui_chat.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,32 @@ def _headless_env() -> dict[str, str]:
return {"QT_QPA_PLATFORM": "offscreen"}
return {}

def _isolated_idausr(tmp_path) -> dict[str, str]:
# conftest points IDAUSR at one directory shared with the idalib session in
# this process. The GUI IDA spawned below must not share it: with the same
# IDAUSR it starts but never creates its windows, so OnCreate never runs.
usr = tmp_path / "idausr"
usr.mkdir(exist_ok=True)
reg = os.path.join(os.environ.get("IDADIR", ""), "ida.reg")
if os.path.isfile(reg):
shutil.copy(reg, usr / "ida.reg")
return {"IDAUSR": str(usr)}

def _ida_path_without_venv(env: dict[str, str]) -> dict[str, str]:
# IDA's embedded Python resolves its site-packages from the first python3
# on PATH. Under `uv run` that is the project venv, which carries PySide6 as
# a dev dependency, so IDA 9.0/9.1 load Qt6 bindings into a Qt5 application
# and PluginForm never builds its widgets. Drop the venv so IDA uses the Qt
# binding it ships with.
venv = env.get("VIRTUAL_ENV")
if not venv:
return env
env = dict(env)
env["PATH"] = os.pathsep.join(
p for p in env.get("PATH", "").split(os.pathsep) if p and not p.startswith(venv)
)
return env


@pytest.mark.skipif(
os.environ.get("REAI_UI_TESTS") != "1",
Expand All @@ -76,12 +102,13 @@ def test_agent_chat_panel_in_gui(tmp_path):
shutil.copy(HELLO_ELF, binary)
report_path = tmp_path / "report.json"
log_path = tmp_path / "ida.log"
env = dict(
env = _ida_path_without_venv(dict(
os.environ,
REAI_UI_REPORT=str(report_path),
REAI_UI_ROOT=ROOT,
**_headless_env(),
)
**_isolated_idausr(tmp_path),
))

proc = subprocess.run(
[ida, "-A", f"-S{RUNNER}", f"-L{log_path}", str(binary)],
Expand All @@ -105,7 +132,11 @@ def test_agent_chat_panel_in_gui(tmp_path):

report = json.loads(report_path.read_text())

assert report["errors"] == [], report["errors"]
assert report["errors"] == [], (
f"{report['errors']}"
f"\nreport: {report}"
f"\nida log tail:\n{log_path.read_text()[-3000:] if log_path.is_file() else '(no log)'}"
)
assert report["ok"] is True
for check in CHECKS:
assert report.get(check) is True, f"{check} failed: {report}"
Loading
Loading