repl: Render evaluated values richly

Encode each result into a bounded tagged tree in the agent so
serializing cyclic or huge objects can never hang, then pretty-print
it host-side with syntax highlighting, depth and breadth caps, and a
hexdump for binary data.
This commit is contained in:
Ole André Vadla Ravnås committed 2026-06-17 13:03:36 +02:00
1 parent 8911f2da0b
commit f7384b5498
5 files changed
+705 -25

No files matched your search

+5 -3
View File
@@ -1,3 +1,5 @@
import { encodeValue } from "./value.js";
class REPL {
#quickCommands = new Map();
@@ -100,10 +102,10 @@ function evaluate(func: () => any) {
const result = func();
if (result instanceof ArrayBuffer) {
return result;
} else {
const type = (result === null) ? "null" : typeof result;
return [type, result];
}
const type = (result === null) ? "null" : typeof result;
const [tree, blob] = encodeValue(result, { maxDepth: 5 });
return ["inspect", type, tree, (blob !== null) ? Array.from(new Uint8Array(blob)) : null];
} catch (exception) {
const e = exception as Error;
return ["error", {
+1
View File
@@ -2,6 +2,7 @@ entrypoint = 'agent.ts'
sources = [
entrypoint,
'value.ts',
'package.json',
'package-lock.json',
'tsconfig.json',
+371
View File
@@ -0,0 +1,371 @@
// Turn an arbitrary value into a bounded, JSON-safe tagged tree (plus a binary
// blob holding any byte buffers), so that serializing it can never hang or blow
// up on cyclic or very large objects such as a ControlFlowGraph. The REPL host
// decodes and pretty-prints the tree.
export interface EncodeOptions {
maxDepth?: number;
}
const DEFAULT_MAX_DEPTH = 3;
export type PackedValue = [EncodedValueTree, ArrayBuffer | null];
export type EncodedValueTree = EncodedValue;
type EncodedValue = [ValueTag, ...any[]];
type BytesEncoded = [tag: ValueTag.Bytes, offset: number, length: number, kind: BytesKind];
type BytesKind = "ArrayBuffer" | "DataView" | TypedArrayName;
const enum ValueTag {
Number = 0,
String = 1,
Object = 2,
Array = 3,
NativePointer = 4,
Null = 5,
Boolean = 6,
Bytes = 7,
Function = 8,
Error = 9,
Undefined = 10,
BigInt = 11,
Symbol = 12,
Date = 13,
RegExp = 14,
Map = 15,
Set = 16,
Promise = 17,
WeakMap = 18,
WeakSet = 19,
DepthLimit = 20,
Circular = 21,
}
type TypedArrayName =
| "Int8Array"
| "Uint8Array"
| "Uint8ClampedArray"
| "Int16Array"
| "Uint16Array"
| "Int32Array"
| "Uint32Array"
| "Float32Array"
| "Float64Array"
| "BigInt64Array"
| "BigUint64Array";
interface EncodeState {
seen: Map<unknown, number>;
nextId: number;
}
export function encodeValue(value: unknown, options?: EncodeOptions): PackedValue {
const chunks: BinaryChunk[] = [];
const maxDepth = options?.maxDepth ?? DEFAULT_MAX_DEPTH;
const state = makeEncodeState();
const tree = collectTree(value, chunks, maxDepth, 0, state);
const { blob, offsets } = packChunks(chunks);
const patchedTree = patchTree(tree, offsets);
return [patchedTree, blob];
}
function makeEncodeState(): EncodeState {
return {
seen: new Map(),
nextId: 1
};
}
function collectTree(
value: unknown,
chunks: BinaryChunk[],
maxDepth: number,
depth: number,
state: EncodeState
): EncodedValueTree {
if (value === null) {
return [ValueTag.Null];
}
const t = typeof value;
if (t === "number") {
return [ValueTag.Number, value as number];
}
if (t === "string") {
return [ValueTag.String, value as string];
}
if (t === "object") {
const obj = value as any;
if (obj instanceof NativePointer) {
return [ValueTag.NativePointer, (obj as NativePointer).toString()];
}
if (isBinaryLike(obj)) {
const index = chunks.length;
const binary = obj as ArrayBufferView | ArrayBuffer;
const kind = getBytesKind(binary);
chunks.push({ index, buf: binary });
return [ValueTag.Bytes, index, kind];
}
if (obj instanceof Error) {
const err = obj as Error;
const name = err.name ?? "Error";
const message = err.message ?? "";
const stack = err.stack ?? "";
return [ValueTag.Error, name, message, stack];
}
if (obj instanceof Date) {
return [ValueTag.Date, (obj as Date).toISOString()];
}
if (obj instanceof RegExp) {
const r = obj as RegExp;
return [ValueTag.RegExp, r.source, r.flags];
}
if (obj instanceof Promise) {
return [ValueTag.Promise];
}
if (obj instanceof WeakMap) {
return [ValueTag.WeakMap];
}
if (obj instanceof WeakSet) {
return [ValueTag.WeakSet];
}
const isArray = Array.isArray(obj);
const isMap = obj instanceof Map;
const isSet = obj instanceof Set;
const existingId = state.seen.get(obj);
if (existingId !== undefined) {
return [ValueTag.Circular, existingId];
}
if (depth >= maxDepth) {
if (isArray) {
return [ValueTag.DepthLimit, ValueTag.Array];
}
if (isMap) {
return [ValueTag.DepthLimit, ValueTag.Map];
}
if (isSet) {
return [ValueTag.DepthLimit, ValueTag.Set];
}
return [ValueTag.DepthLimit, ValueTag.Object];
}
const id = state.nextId++;
state.seen.set(obj, id);
if (isMap) {
const mapValue = obj as Map<unknown, unknown>;
const entries: [EncodedValueTree, EncodedValueTree][] = [];
for (const [k, v] of mapValue.entries()) {
const keyTree = collectTree(k, chunks, maxDepth, depth + 1, state);
const valueTree = collectTree(v, chunks, maxDepth, depth + 1, state);
entries.push([keyTree, valueTree]);
}
return [ValueTag.Map, id, entries];
}
if (isSet) {
const setValue = obj as Set<unknown>;
const items: EncodedValueTree[] = [];
for (const v of setValue.values()) {
items.push(collectTree(v, chunks, maxDepth, depth + 1, state));
}
return [ValueTag.Set, id, items];
}
if (isArray) {
const elements = (obj as unknown[]).map(v =>
collectTree(v, chunks, maxDepth, depth + 1, state),
);
return [ValueTag.Array, id, elements];
}
try {
const maybeToJSON = obj.toJSON;
if (typeof maybeToJSON === "function") {
const converted = maybeToJSON.call(obj);
return collectTree(converted, chunks, maxDepth, depth, state);
}
} catch {
}
const entries: [EncodedValueTree, EncodedValueTree][] = [];
for (const k of Object.keys(obj as Record<string, unknown>)) {
const keyTree: EncodedValueTree = [ValueTag.String, k];
let v;
try {
v = obj[k];
} catch {
continue;
}
const valueTree = collectTree(v, chunks, maxDepth, depth + 1, state);
entries.push([keyTree, valueTree]);
}
const symbols = Object.getOwnPropertySymbols(obj as object);
for (const s of symbols) {
if (!Object.prototype.propertyIsEnumerable.call(obj, s)) {
continue;
}
const keyTree: EncodedValueTree = [ValueTag.Symbol, String(s)];
let v;
try {
v = obj[s];
} catch {
continue;
}
const valueTree = collectTree(v, chunks, maxDepth, depth + 1, state);
entries.push([keyTree, valueTree]);
}
return [ValueTag.Object, id, entries];
}
if (t === "boolean") {
return [ValueTag.Boolean, value as boolean];
}
if (t === "function") {
const fn = value as Function;
const name = fn.name ?? "";
const sig = name === "" ? "[Function]" : `[Function: ${name}]`;
return [ValueTag.Function, sig];
}
if (t === "undefined") {
return [ValueTag.Undefined];
}
if (t === "bigint") {
const big = value as bigint;
return [ValueTag.BigInt, big.toString()];
}
if (t === "symbol") {
return [ValueTag.Symbol, String(value)];
}
return [ValueTag.Undefined];
}
interface BinaryChunk {
index: number;
buf: ArrayBufferView | ArrayBuffer;
}
function packChunks(chunks: BinaryChunk[]): {
blob: ArrayBuffer | null;
offsets: { offset: number; length: number }[];
} {
if (chunks.length === 0) {
return { blob: null, offsets: [] };
}
let total = 0;
const lengths = chunks.map(({ buf }) => {
const b = buf instanceof ArrayBuffer ? buf : buf.buffer;
return b.byteLength;
});
for (const len of lengths) {
total += len;
}
const blob = new Uint8Array(total);
const offsets: { offset: number; length: number }[] = [];
let offset = 0;
chunks.forEach(({ buf }, index) => {
const raw = buf instanceof ArrayBuffer
? new Uint8Array(buf)
: new Uint8Array(buf.buffer, buf.byteOffset, buf.byteLength);
blob.set(raw, offset);
offsets[index] = { offset, length: raw.byteLength };
offset += raw.byteLength;
});
return { blob: blob.buffer as ArrayBuffer, offsets };
}
function patchTree(
tree: EncodedValueTree,
offsets: { offset: number; length: number }[]
): EncodedValueTree {
const [tag, ...rest] = tree;
switch (tag) {
case ValueTag.Object: {
const id = rest[0] as number;
const entries = rest[1] as [EncodedValueTree, EncodedValueTree][];
const patchedEntries = entries.map(([k, v]) => [
patchTree(k, offsets),
patchTree(v, offsets),
] as [EncodedValueTree, EncodedValueTree]);
return [ValueTag.Object, id, patchedEntries];
}
case ValueTag.Array: {
const id = rest[0] as number;
const items = (rest[1] as EncodedValueTree[])
.map(child => patchTree(child, offsets));
return [ValueTag.Array, id, items];
}
case ValueTag.Bytes: {
const index = rest[0] as number;
const kind = rest[1] as BytesKind;
const { offset, length } = offsets[index];
return [ValueTag.Bytes, offset, length, kind] as BytesEncoded;
}
case ValueTag.Map: {
const id = rest[0] as number;
const entries = rest[1] as [EncodedValueTree, EncodedValueTree][];
const patchedEntries = entries.map(([k, v]) => [
patchTree(k, offsets),
patchTree(v, offsets),
] as [EncodedValueTree, EncodedValueTree]);
return [ValueTag.Map, id, patchedEntries];
}
case ValueTag.Set: {
const id = rest[0] as number;
const items = rest[1] as EncodedValueTree[];
const patchedItems = items.map(child => patchTree(child, offsets));
return [ValueTag.Set, id, patchedItems];
}
default:
return tree;
}
}
function getBytesKind(v: ArrayBufferView | ArrayBuffer): BytesKind {
if (v instanceof ArrayBuffer) {
return "ArrayBuffer";
}
if (v instanceof DataView) {
return "DataView";
}
const ctor = (v as { constructor?: { name?: string } }).constructor;
const name = ctor?.name ?? "ArrayBuffer";
return name as BytesKind;
}
function isBinaryLike(v: unknown): v is ArrayBufferView | ArrayBuffer {
return v instanceof ArrayBuffer || ArrayBuffer.isView(v);
}
+13 -22
View File
@@ -28,7 +28,7 @@ from prompt_toolkit.styles import Style as PromptToolkitStyle
from pygments.lexers.javascript import JavascriptLexer
from pygments.token import Token
from frida_tools import _repl_magic
from frida_tools import _repl_magic, repl_inspect
from frida_tools.application import ConsoleApplication
from frida_tools.cli_formatting import THEME_COLOR, format_compiled, format_compiling, format_diagnostic
from frida_tools.reactor import Reactor
@@ -482,12 +482,11 @@ class REPLApplication(ConsoleApplication):
success = False
try:
t, value = self._perform_on_reactor_thread(lambda: exec(arg))
if t in ("function", "undefined", "null"):
output = t
elif t == "binary":
output = hexdump(value).rstrip("\n")
if t == "binary":
output = repl_inspect.render_hexdump(value)
else:
output = json.dumps(value, sort_keys=True, indent=4, separators=(",", ": "))
tree, blob = value
output = "undefined" if repl_inspect.is_undefined(tree) else repl_inspect.render(tree, blob)
success = True
except JavaScriptError as e:
error = e.error
@@ -529,7 +528,8 @@ class REPLApplication(ConsoleApplication):
obj_type, obj_value = self._evaluate_expression(obj_to_identify)
if obj_type == "function":
signature = self._evaluate_expression("%s.toString()" % obj_to_identify)[1]
_, (signature_tree, _) = self._evaluate_expression("%s.toString()" % obj_to_identify)
signature = signature_tree[1]
clean_signature = signature.split("{")[0][:-1].split("function ")[-1]
if "[native code]" in signature:
@@ -549,9 +549,9 @@ class REPLApplication(ConsoleApplication):
help_text += "Docstring: #TODO :)"
elif obj_type == "string":
bool_text = self._evaluate_expression(obj_to_identify + ".toString()")[1]
_, (text_tree, _) = self._evaluate_expression(obj_to_identify + ".toString()")
help_text += "Type: Boolean\n"
help_text += f"Text: {bool_text.decode()}\n"
help_text += f"Text: {text_tree[1]}\n"
help_text += "Docstring: #TODO :)"
self._print(help_text)
@@ -681,7 +681,8 @@ class REPLApplication(ConsoleApplication):
return ("binary", bytes())
elif result[0] == "error":
raise JavaScriptError(result[1])
return (result[0], result[1])
_, value_type, tree, blob = result
return (value_type, (tree, blob))
def _process_message(self, message: Mapping[Any, Any], data: Any) -> None:
message_type = message["type"]
@@ -1096,7 +1097,8 @@ class FridaCompleter(Completer):
if t == "error":
return []
return sorted(filter(self._is_valid_name, set(value)))
names = repl_inspect.to_string_list(value[0])
return sorted(filter(self._is_valid_name, set(names)))
def _is_valid_name(self, name) -> bool:
tokens = list(self._lexer.get_tokens(name))
@@ -1112,17 +1114,6 @@ def script_needs_compilation(path: AnyStr) -> bool:
return path.endswith(b".ts")
def hexdump(src, length: int = 16) -> str:
FILTER = "".join([(len(repr(chr(x))) == 3) and chr(x) or "." for x in range(256)])
lines = []
for c in range(0, len(src), length):
chars = src[c : c + length]
hex = " ".join(["%02x" % x for x in iter(chars)])
printable = "".join(["%s" % ((x <= 127 and FILTER[x]) or ".") for x in iter(chars)])
lines.append("%04x %-*s %s\n" % (c, length * 3, hex, printable))
return "".join(lines)
OS_BINARY_SIGNATURES = {
b"\x4d\x5a", # PE
b"\xca\xfe\xba\xbe", # Fat Mach-O
+315
View File
@@ -0,0 +1,315 @@
"""Rendering of JavaScript values inspected by the REPL agent.
The agent encodes each evaluated value into a bounded tagged tree, so that
serializing cyclic or very large objects can never hang. This module
pretty-prints that tree with syntax highlighting.
"""
from typing import Any, List, Sequence, Tuple
from colorama import Fore, Style
# Tags mirror ValueTag in agents/repl/agent.ts.
NUMBER = 0
STRING = 1
OBJECT = 2
ARRAY = 3
NATIVE_POINTER = 4
NULL = 5
BOOLEAN = 6
BYTES = 7
FUNCTION = 8
ERROR = 9
UNDEFINED = 10
BIGINT = 11
SYMBOL = 12
DATE = 13
REGEXP = 14
MAP = 15
SET = 16
PROMISE = 17
WEAKMAP = 18
WEAKSET = 19
DEPTH_LIMIT = 20
CIRCULAR = 21
_CYAN = Fore.CYAN
_MINT = Fore.GREEN
_KEY = Fore.LIGHTGREEN_EX
_ORANGE = Fore.YELLOW
_PURPLE = Fore.MAGENTA
_RED = Fore.RED
_BLUE = Fore.BLUE
_GRAY = Fore.LIGHTBLACK_EX
_RESET = Style.RESET_ALL
_MAX_ITEMS = 100
_HEX_BYTES_PER_ROW = 16
_CONTAINER_NAMES = {OBJECT: "Object", ARRAY: "Array", MAP: "Map", SET: "Set"}
Node = Sequence[Any]
def render(tree: Node, blob: Sequence[int]) -> str:
out: List[str] = []
_append(tree, 0, out, blob)
return "".join(out)
def render_hexdump(data: bytes) -> str:
rows = []
for offset in range(0, len(data), _HEX_BYTES_PER_ROW):
rows.append(_hex_row(offset, data[offset:offset + _HEX_BYTES_PER_ROW]))
return "\n".join(rows)
def is_undefined(tree: Node) -> bool:
return tree[0] == UNDEFINED
def to_string_list(tree: Node) -> List[str]:
return [element[1] for element in tree[2]]
def _append(node: Node, level: int, out: List[str], blob: Sequence[int]) -> None:
tag = node[0]
if tag == NUMBER:
out.append(_color(_format_number(node[1]), _CYAN))
elif tag == STRING:
out.append(_color('"%s"' % node[1], _MINT))
elif tag == OBJECT:
_append_object(node, level, out, blob)
elif tag == ARRAY:
_append_array(node, level, out, blob)
elif tag == NATIVE_POINTER:
out.append(_color(node[1], _ORANGE))
elif tag == NULL:
out.append(_color("null", _ORANGE))
elif tag == BOOLEAN:
out.append(_color("true" if node[1] else "false", _ORANGE))
elif tag == BYTES:
_append_bytes(node, level, out, blob)
elif tag == FUNCTION:
out.append(_color(node[1], _PURPLE))
elif tag == ERROR:
_append_error(node, level, out)
elif tag == UNDEFINED:
out.append(_color("undefined", _ORANGE))
elif tag == BIGINT:
out.append(_color(node[1] + "n", _CYAN))
elif tag == SYMBOL:
out.append(_color(node[1], _PURPLE))
elif tag == DATE:
out.append(_color("Date(", _BLUE))
out.append(_color(node[1], _MINT))
out.append(_color(")", _BLUE))
elif tag == REGEXP:
_append_regexp(node, out)
elif tag == MAP:
_append_map(node, level, out, blob)
elif tag == SET:
_append_set(node, level, out, blob)
elif tag == PROMISE:
out.append(_color("Promise", _PURPLE))
elif tag == WEAKMAP:
out.append(_color("WeakMap", _PURPLE))
elif tag == WEAKSET:
out.append(_color("WeakSet", _PURPLE))
elif tag == DEPTH_LIMIT:
out.append(_color("%s<depth limit reached>" % _CONTAINER_NAMES[node[1]], _ORANGE))
elif tag == CIRCULAR:
out.append(_color("⟳ circular *%d" % node[1], _ORANGE))
def _append_object(node: Node, level: int, out: List[str], blob: Sequence[int]) -> None:
properties = node[2]
if not properties:
out.append(_color("{}", _CYAN))
return
out.append(_color("{", _CYAN))
out.append("\n")
shown, hidden = _capped(properties)
for index, (key, value) in enumerate(shown):
_indent(level + 1, out)
out.append(_color(key[1], _KEY))
out.append(": ")
_append(value, level + 1, out, blob)
if _has_more(index, shown, hidden):
out.append(",")
out.append("\n")
_append_overflow(hidden, level + 1, out)
_indent(level, out)
out.append(_color("}", _CYAN))
def _append_array(node: Node, level: int, out: List[str], blob: Sequence[int]) -> None:
elements = node[2]
if not elements:
out.append("[]")
return
out.append("[")
out.append("\n")
shown, hidden = _capped(elements)
for index, element in enumerate(shown):
_indent(level + 1, out)
_append(element, level + 1, out, blob)
if _has_more(index, shown, hidden):
out.append(",")
out.append("\n")
_append_overflow(hidden, level + 1, out)
_indent(level, out)
out.append("]")
def _append_bytes(node: Node, level: int, out: List[str], blob: Sequence[int]) -> None:
offset, length, kind = node[1], node[2], node[3]
out.append(_color("Bytes(", _MINT))
out.append(_color(kind, _CYAN))
out.append(_color("[%d])" % length, _MINT))
data = bytes(blob[offset:offset + length])
for start in range(0, len(data), _HEX_BYTES_PER_ROW):
out.append("\n")
_indent(level + 1, out)
out.append(_hex_row(start, data[start:start + _HEX_BYTES_PER_ROW]))
def _append_error(node: Node, level: int, out: List[str]) -> None:
name, message, stack = node[1], node[2], node[3]
out.append(_color(name if not message else "%s: %s" % (name, message), _RED))
if not stack:
return
for line in stack.split("\n"):
out.append("\n")
_indent(level + 1, out)
out.append(Style.DIM + line + _RESET)
def _append_regexp(node: Node, out: List[str]) -> None:
pattern, flags = node[1], node[2]
out.append(_color("/", _PURPLE))
out.append(_color(pattern, _MINT))
out.append(_color("/", _PURPLE))
if flags:
out.append(_color(flags, _PURPLE))
def _append_map(node: Node, level: int, out: List[str], blob: Sequence[int]) -> None:
entries = node[2]
if not entries:
out.append(_color("Map{}", _CYAN))
return
out.append(_color("Map{", _CYAN))
out.append("\n")
shown, hidden = _capped(entries)
for index, (key, value) in enumerate(shown):
_indent(level + 1, out)
_append(key, level + 1, out, blob)
out.append(" => ")
_append(value, level + 1, out, blob)
if _has_more(index, shown, hidden):
out.append(",")
out.append("\n")
_append_overflow(hidden, level + 1, out)
_indent(level, out)
out.append(_color("}", _CYAN))
def _append_set(node: Node, level: int, out: List[str], blob: Sequence[int]) -> None:
elements = node[2]
if not elements:
out.append(_color("Set[]", _CYAN))
return
out.append(_color("Set[", _CYAN))
out.append("\n")
shown, hidden = _capped(elements)
for element in shown:
_indent(level + 1, out)
out.append(_color("• ", _CYAN))
_append(element, level + 1, out, blob)
out.append("\n")
_append_overflow(hidden, level + 1, out)
_indent(level, out)
out.append(_color("]", _CYAN))
def _capped(items: Sequence[Any]) -> Tuple[Sequence[Any], int]:
shown = items[:_MAX_ITEMS]
return shown, len(items) - len(shown)
def _has_more(index: int, shown: Sequence[Any], hidden: int) -> bool:
return index < len(shown) - 1 or hidden > 0
def _append_overflow(hidden: int, level: int, out: List[str]) -> None:
if not hidden:
return
_indent(level, out)
out.append(_color("… %d more" % hidden, _ORANGE))
out.append("\n")
def _hex_row(offset: int, chunk: bytes) -> str:
cells = " ".join(_color("%02X" % byte, _byte_color(byte)) for byte in chunk)
padding = " " * (_HEX_BYTES_PER_ROW - len(chunk))
glyphs = "".join(_color(_ascii_glyph(byte), _GRAY) for byte in chunk)
return "%s %s%s %s" % (_color("%08X" % offset, _KEY), cells, padding, glyphs)
def _byte_color(byte: int) -> str:
if byte == 0x00:
return _GRAY
if 0x20 <= byte <= 0x7E:
return _MINT
if byte <= 0x1F or byte == 0x7F:
return _ORANGE
return _CYAN
def _ascii_glyph(byte: int) -> str:
return chr(byte) if 0x20 <= byte <= 0x7E else "."
def _format_number(value: Any) -> str:
if value is None:
return "null"
if isinstance(value, float):
return str(int(value)) if value.is_integer() else repr(value)
return str(value)
def _color(text: str, color: str) -> str:
return color + text + _RESET
def _indent(level: int, out: List[str]) -> None:
if level > 0:
out.append(" " * level)