diff --git a/declib/api/decompiler_client.py b/declib/api/decompiler_client.py index 97aa203..a200483 100644 --- a/declib/api/decompiler_client.py +++ b/declib/api/decompiler_client.py @@ -573,6 +573,10 @@ def list_imports(self) -> List: """Return (lifted_addr, name, library) tuples for imported symbols.""" return self._send_request({"type": "method_call", "method_name": "list_imports"}) + def backend_eval(self, code: str, mode: str = "eval") -> Dict: + """UNSAFE: run arbitrary Python in the backend process. See the interface docstring.""" + return self._send_request({"type": "method_call", "method_name": "backend_eval", "args": [code], "kwargs": {"mode": mode}}) + def define_function(self, addr: int) -> bool: """Create a function at ``addr`` (lifted).""" return self._send_request({"type": "method_call", "method_name": "define_function", "args": [addr]}) diff --git a/declib/api/decompiler_interface.py b/declib/api/decompiler_interface.py index a7e676d..c47d5d7 100644 --- a/declib/api/decompiler_interface.py +++ b/declib/api/decompiler_interface.py @@ -668,6 +668,41 @@ def list_imports(self) -> List[Tuple[int, str, str]]: """ raise NotImplementedError("Import listing is not implemented for this backend.") + def backend_eval(self, code: str, mode: str = "eval") -> Dict: + """UNSAFE escape hatch: run arbitrary Python inside the backend process. + + The code executes with this interface bound to ``deci`` and full access + to the backend's own Python API (``idaapi``, the Ghidra ``flat_api`` via + ``deci.flat_api``, ``deci.project`` for angr, ``deci.bv`` for Binary + Ninja, ...). This is deliberately backend-specific and not portable — it + exists for the cases the abstracted API doesn't cover. + + @param code: Python source. ``mode='eval'`` evaluates a single + expression and returns its ``repr``; ``mode='exec'`` runs + statements, capturing stdout and any variable named ``result``. + @return: ``{"ok", "result", "stdout", "traceback"}``. (The failure key is + ``traceback`` rather than ``error`` on purpose — the client/server + wire protocol reserves a top-level ``error`` key for RPC failures.) + """ + import io + import contextlib + import traceback as _traceback + + env = {"deci": self, "__name__": "__declib_backend_eval__"} + out = io.StringIO() + try: + with contextlib.redirect_stdout(out): + if mode == "exec": + exec(code, env) # noqa: S102 - intentional escape hatch + value = env.get("result", None) + result = repr(value) if value is not None else "" + else: + result = repr(eval(code, env)) # noqa: S307 - intentional escape hatch + return {"ok": True, "result": result, "stdout": out.getvalue(), "traceback": None} + except Exception: # noqa: BLE001 - surface any backend error verbatim + return {"ok": False, "result": None, "stdout": out.getvalue(), + "traceback": _traceback.format_exc()} + def get_callgraph(self, only_names=False) -> nx.DiGraph: """ Returns the callgraph of the binary. This is a dict of function addresses to a list of function addresses diff --git a/declib/cli/decompiler_cli.py b/declib/cli/decompiler_cli.py index b4a1d9d..4911b2c 100644 --- a/declib/cli/decompiler_cli.py +++ b/declib/cli/decompiler_cli.py @@ -22,6 +22,7 @@ - define define a function/code/data at an address - undefine clear code/data at an address - patch set/get/list/delete byte patches +- eval / exec UNSAFE: run Python in the backend process (escape hatch) - global list/get/rename/retype global variables - signature get/set a function's full signature (prototype) - rename rename a function or local variable @@ -1319,6 +1320,49 @@ def cmd_read_memory(args) -> int: return 0 +# --------------------------------------------------------------------------- +# eval / exec — UNSAFE backend scripting escape hatch +# --------------------------------------------------------------------------- + +def _run_backend_script(args, code: str, mode: str) -> int: + with _with_client(args) as client: + res = client.backend_eval(code, mode=mode) + if not res.get("ok"): + if res.get("stdout"): + sys.stdout.write(res["stdout"]) + print(res.get("traceback") or f"{mode} failed", file=sys.stderr) + if args.json: + _emit(args, res) + return EXIT_RUNTIME_ERROR + if args.json: + _emit(args, res) + else: + if res.get("stdout"): + sys.stdout.write(res["stdout"]) + if res.get("result"): + print(res["result"]) + return EXIT_OK + + +def cmd_eval(args) -> int: + """UNSAFE: evaluate a Python expression inside the backend process.""" + return _run_backend_script(args, args.expression, "eval") + + +def cmd_exec(args) -> int: + """UNSAFE: execute Python code inside the backend process.""" + if args.file: + try: + code = Path(args.file).read_text() + except OSError as exc: + raise SystemExit(f"Could not read {args.file!r}: {exc}") + else: + code = args.code + if not code: + raise SystemExit("Provide Python code as an argument or via --file.") + return _run_backend_script(args, code, "exec") + + # --------------------------------------------------------------------------- # patch (set / get / list / delete) # --------------------------------------------------------------------------- @@ -2113,6 +2157,28 @@ def _add_comment_addr_action(name, help_text, with_text=False): _add_output_args(p_gc) p_gc.set_defaults(func=cmd_get_callers) + # eval / exec (UNSAFE backend scripting) + p_eval = sub.add_parser( + "eval", + help="UNSAFE, backend-specific: evaluate a Python expression in the backend process.", + ) + p_eval.add_argument("expression", + help="Python expression; `deci` is the DecompilerInterface (idaapi, " + "deci.flat_api, deci.project, deci.bv are reachable).") + _add_server_filter_args(p_eval) + _add_output_args(p_eval) + p_eval.set_defaults(func=cmd_eval) + + p_exec = sub.add_parser( + "exec", + help="UNSAFE, backend-specific: execute Python code in the backend process.", + ) + p_exec.add_argument("code", nargs="?", help="Python source (or use --file).") + p_exec.add_argument("--file", dest="file", help="Read Python source from a file instead.") + _add_server_filter_args(p_exec) + _add_output_args(p_exec) + p_exec.set_defaults(func=cmd_exec) + # patch p_patch = sub.add_parser("patch", help="Apply, inspect, list, or revert byte patches.") patch_sub = p_patch.add_subparsers(dest="patch_action", required=True) diff --git a/declib/skills/decompiler/SKILL.md b/declib/skills/decompiler/SKILL.md index fbe1f79..ce26cc8 100644 --- a/declib/skills/decompiler/SKILL.md +++ b/declib/skills/decompiler/SKILL.md @@ -159,6 +159,8 @@ same binary. | `patch set ` | Patch bytes at an address. | same | | `patch get/delete ` | Show or revert the patch at an address (IDA). | same | | `patch list` | List all byte patches (IDA). | same | +| `eval ""` | **UNSAFE**: evaluate a Python expression in the backend process. | same | +| `exec ""` / `exec --file

` | **UNSAFE**: run Python in the backend process. | `--file`, same | | `read int/string/struct [...]` | Typed reads: decode memory as an integer, C string, or defined struct. | `--size`, `--signed`, `--endian`, `--max-len`, `--encoding`, same + `--json` | | `read_memory ` | Read raw bytes from the binary at ``. Default output is a hexdump. | `--format {hexdump,hex,raw}`, same + `--json` (base64-encoded bytes) | | `install-skill` | Install this file for Claude Code or Codex. | `--agent`, `--dest`, `--force`, `--json` | @@ -244,6 +246,27 @@ purely in-memory: `save` exits `2` (`not implemented`) and there is nothing to reload. IDA reopens the saved `.i64` directly (no re-analysis), so a reload after `save` is fast and lossless. +### `eval` / `exec` — UNSAFE backend scripting (escape hatch) + +When the abstracted API doesn't cover what you need, drop to the backend's own +Python. **This runs arbitrary code inside the backend process** — it is not +portable and not sandboxed. `deci` is the live DecompilerInterface; the backend +API is reachable through it (`idaapi` importable; `deci.flat_api` on Ghidra; +`deci.project` on angr; `deci.bv` on Binary Ninja). + +```bash +decompiler eval "deci.name" # -> 'ida' +decompiler eval "len(list(deci.functions.keys()))" # count functions +decompiler exec "print(hex(deci.binary_base_addr))" # captured stdout +decompiler exec "result = [f.name for _,f in deci.functions.items()][:5]" +decompiler exec --file ./my_ida_script.py # run a whole script +``` + +`eval` returns the expression's `repr`; `exec` captures stdout and the value of +a variable named `result`. On error, the CLI prints the traceback and exits +non-zero. Prefer the first-class commands (`rename`, `retype`, `comment`, ...) +when they exist — reach for `eval`/`exec` only for backend-specific gaps. + ### `patch` — modify bytes ```bash diff --git a/docs/decompiler_cli.md b/docs/decompiler_cli.md index 2cb21e7..e897a8c 100644 --- a/docs/decompiler_cli.md +++ b/docs/decompiler_cli.md @@ -439,6 +439,30 @@ decompiler get_callers [--id ID] [--binary PATH] [--backend BACKEND] [- Unlike `xref_to`, this never returns globals or other data refs. Rows are always of kind `Function`. +### `eval` / `exec` (UNSAFE backend scripting) + +An escape hatch for backend-specific work the abstracted API doesn't cover. +**This executes arbitrary Python inside the backend process** — not portable, +not sandboxed. + +```bash +decompiler eval "" [--id ID] [--json] +decompiler exec "" [--id ID] [--json] +decompiler exec --file [--id ID] [--json] +``` + +`deci` is the live `DecompilerInterface`; the backend's own API is reachable +through it (`idaapi` importable, `deci.flat_api` on Ghidra, `deci.project` on +angr, `deci.bv` on Binary Ninja). `eval` returns the expression's `repr`; +`exec` captures stdout and the value of a variable named `result`. Errors print +a traceback and exit non-zero. + +```bash +decompiler eval "deci.name" +decompiler exec "print(hex(deci.binary_base_addr))" +decompiler exec --file ./analysis.py +``` + ### `patch` Apply, inspect, list, or revert byte patches. diff --git a/tests/test_decompiler_cli.py b/tests/test_decompiler_cli.py index 708bb48..049fa30 100644 --- a/tests/test_decompiler_cli.py +++ b/tests/test_decompiler_cli.py @@ -350,6 +350,51 @@ def test_read_memory_invalid_address(self): combined = result.stdout + result.stderr self.assertNotIn("|.ELF", combined) + # ------------------------------------------------------------------- + # eval / exec (backend scripting escape hatch) + # ------------------------------------------------------------------- + + def test_eval_expression(self): + self._load_fauxware_isolated() + result = _run_cli("eval", "1 + 2", "--json") + payload = json.loads(result.stdout) + self.assertTrue(payload["ok"]) + self.assertEqual(payload["result"], "3") + + def test_eval_has_backend_access(self): + """`deci` is bound and the backend is reachable from eval.""" + self._load_fauxware_isolated() + result = _run_cli("eval", "len(list(deci.functions.keys())) > 0", "--json") + payload = json.loads(result.stdout) + self.assertTrue(payload["ok"], f"{self.backend}: eval failed: {payload}") + self.assertEqual(payload["result"], "True") + # deci.name reflects the running backend. + name = _run_cli("eval", "deci.name", "--json") + self.assertIn(self.backend, json.loads(name.stdout)["result"]) + + def test_exec_captures_stdout(self): + self._load_fauxware_isolated() + result = _run_cli("exec", "print('hello from', deci.name)", "--json") + payload = json.loads(result.stdout) + self.assertTrue(payload["ok"]) + self.assertIn("hello from", payload["stdout"]) + self.assertIn(self.backend, payload["stdout"]) + + def test_exec_result_variable(self): + self._load_fauxware_isolated() + result = _run_cli("exec", "result = 40 + 2", "--json") + payload = json.loads(result.stdout) + self.assertTrue(payload["ok"]) + self.assertEqual(payload["result"], "42") + + def test_eval_error_is_reported(self): + self._load_fauxware_isolated() + result = _run_cli("eval", "1/0", "--json", check=False) + self.assertNotEqual(result.returncode, 0) + payload = json.loads(result.stdout) + self.assertFalse(payload["ok"]) + self.assertIn("ZeroDivisionError", payload["traceback"]) + # ------------------------------------------------------------------- # patching # -------------------------------------------------------------------