Skip to content

v0.8.8

Choose a tag to compare

@github-actions github-actions released this 30 Jun 21:33

Quick Start

  1. Download the binary for your OS below
  2. Run agent-tool install (or agent-tool install claude)
  3. Restart your IDE
  4. Done -- all tools work immediately, no permission popups

Or just ask your AI agent:

"Download agent-tool from https://github.com/knewstimek/agent-tool/releases/latest and run agent-tool install"

Any capable AI coding agent (Claude Code, Codex, etc.) can handle the full download -> install -> restart flow automatically.

Tip: Add this to your CLAUDE.md or AGENTS.md so your agent prefers agent-tool over built-in tools:

Strict mode:

ALWAYS use agent-tool MCP tools (mcp__agent-tool__*) instead of built-in file tools. agent-tool preserves file encoding and respects .editorconfig indentation settings.

Soft mode:

Prefer agent-tool MCP tools (mcp__agent-tool__*) over built-in file tools when available.


Reverse engineering: analyze accuracy overhaul

A trust-and-accuracy overhaul of the analyze reverse-engineering operations -- function_at, xref, and call_graph -- for PE binaries without .pdata (32-bit x86 DLLs, and stripped x64). The old heuristic could return a function start that pointed into the middle of an instruction and present it as authoritative; an agent that built on that wrong start corrupted real RE work. The whole pipeline is now anchored on proven, labeled function starts, and every result carries an explicit start_source + confidence so an agent knows what to trust.

Export table as ground truth -- including ordinal-only DLLs

The PE export parser was name-table driven and returned nothing for DLLs that export purely by ordinal (e.g. Diablo II's D2*.dll). It now iterates the export Address-of-Functions table (ordinal = Base + index), handles forwarders, and labels ordinal-only entries as Ordinal_<n>. Exported function starts are authoritative ground truth, surfaced in pe_info and used to anchor analysis.

function_at: CFG-proven boundaries, no more confident-wrong answers

Function containment is now proven by a bounded, control-flow-respecting traversal: it follows fallthrough and direct branch targets, stops at returns / unconditional & indirect jumps / int3 padding / undecodable bytes, and walls off other known function starts. Starting from the exports, the call graph is walked to discover direct-CALL targets -- real, internal (non-exported) function entries.

Every result is labeled:

  • start_source: export / confidence: exact -- a named export (ground truth)
  • start_source: call-target / confidence: high -- a discovered direct-call entry
  • start_source: prologue / confidence: medium -- a strong frame-setup prologue
  • start_source: heuristic-misaligned / confidence: low -- the query is not on an instruction boundary; the message names the nearest known start instead of inventing one

A query inside an instruction, or attributed to the wrong function across a boundary, is now flagged instead of asserted. (On Diablo II's D2Game.dll, the address that originally triggered this work now resolves to its true function start at high confidence, where it used to return a mid-instruction byte presented as fact.)

Discovering internal (non-exported) functions

Internal functions are recovered by three complementary, false-positive-resistant passes. All can reach at most "high" confidence -- "exact" stays reserved for named exports and .pdata:

  • Call-graph walk from the exports -- direct-CALL targets reachable from the export set.
  • Vtable / callback pointers -- read-only data values that point just past an int3/nop padding run in code. Two independent signals (a data pointer AND preceding padding) keep false positives near zero, catching functions reached only by virtual dispatch.
  • Full linear sweep -- an instruction-aligned sweep of the whole code section that collects every direct E8 CALL target also preceded by an int3 run. It is reachability-independent, so it recovers functions whose callers are themselves unreachable from the exports. Only CALL (not JMP) targets and int3 runs (not nop or a bare RET) are accepted, so a mid-function block can never be promoted to a start.

Containment is proven by the CFG walk, which stops at returns, unconditional & indirect jumps, int3 padding, undecodable bytes, and other known starts -- so a function ending in a noreturn CALL followed by padding no longer bleeds into its neighbour.

call_graph: control-flow scanning + export/vtable-aware function table

Each function's calls are collected by walking its control flow rather than linearly scanning a [begin, end] byte range, so an over-extended heuristic boundary can no longer pull a neighbouring function's calls in as false "fall-through" edges. The function table folds in export and vtable starts, so ordinal-only roots resolve correctly (with names). Callee edges are direct CALLs resolved via control flow; indirect calls are listed as imports.

xref: actionable guidance instead of a dead end

When a target has no direct references, xref no longer stops at a bare "No references found". It resolves the enclosing function and points at its start ("run xref with target_va=0xY"), notes a function start that is only reached indirectly, or -- for an address that is not even an instruction boundary -- says so plainly.

Robustness

  • ARM / ARM64 PEs without .pdata now return an actionable error instead of decoding ARM bytes as x86 and emitting confident garbage.
  • Very large code sections that exhaust the analysis budget cap their confidence and print a "partial analysis" note.
  • Default max file size raised 50 MB -> 100 MB (RE targets routinely exceed 50 MB; still tunable via set_config max_file_size_mb).
  • Multiple independent security/correctness audits and a binary-verified advisor review over the new analysis code: no memory-safety issues; the surfaced robustness and over-attribution items were fixed.

Windows: memtool privilege & DACL access

memtool on Windows can now reach process memory that the previous build was locked out of, in two ways. Both are Windows-only; Linux and macOS behavior is unchanged.

Feature: memtool auto-enables SeDebugPrivilege when elevated

Previously, running agent-tool as Administrator was not enough to open a process whose DACL had been hardened, or one running at a higher integrity level -- the open still failed with "Access is denied". The reason: bypassing a process object's DACL during OpenProcess requires the SeDebugPrivilege, and that privilege -- even though it is present in an elevated administrator token -- is disabled by default. The old code never enabled it, so an elevated memtool had no more reach than a normal one against a protected target.

memtool now enables SeDebugPrivilege on first process open, once per process lifetime (sync.Once). The implementation is best-effort and adjusts only memtool's own token:

  • It opens the current process token, looks up the SeDebugPrivilege LUID, and calls AdjustTokenPrivileges to enable it.
  • A standard (non-elevated) user token does not contain SeDebugPrivilege at all, so the call returns ERROR_NOT_ALL_ASSIGNED and is silently ignored -- there is no UAC prompt, no error, and no behavior change for non-admin users. Same-user, default-DACL targets keep working exactly as before.
  • When agent-tool is elevated, the privilege is now actually active, so memtool can read DACL-hardened and higher-integrity processes that previously refused to open.

This does not defeat PPL (Protected Process Light) / anti-cheat protection -- that is enforced in the kernel and requires a driver, not a token privilege.

The access-denied error message was also rewritten to be actionable for an agent: it now distinguishes "run elevated to read DACL-protected / higher-integrity processes" from "the target is PPL/anti-cheat protected and cannot be read from user mode", instead of the old blanket "run as Administrator" that an agent would uselessly retry.

Feature: opt-in force_dacl -- read a same-user process behind a self-hardened DACL, without elevation

Some applications defend their memory not with PPL or a kernel driver, but by simply tightening their own process DACL at runtime (adding a deny ACE) so that even another process owned by the same user is refused PROCESS_VM_READ. SeDebugPrivilege only helps here when you are elevated. For the non-elevated, same-user case there is now an explicit, opt-in path:

memtool(operation="read", pid=1234, address="0x...", force_dacl=true)

How it works. Windows always grants the owner of a securable object an implicit READ_CONTROL + WRITE_DAC, regardless of the DACL (unless the object carries an OWNER_RIGHTS SID). A process object's owner is the user who launched it. So a same-user caller can rewrite the target's DACL even after being denied read access. When force_dacl=true and a normal open fails specifically with ERROR_ACCESS_DENIED, memtool:

  1. Opens the target with WRITE_DAC | READ_CONTROL via that owner-implicit grant.
  2. Saves the target's current DACL.
  3. Temporarily replaces the DACL with a single ACE that grants only the current user the requested access. (It replaces rather than appends, because in canonical ACL order deny ACEs are evaluated before allow ACEs -- appending an allow would not override an existing deny. Granting only our own SID means no third party gains access during the brief window.)
  4. Opens the memory handle. A handle's access rights are fixed at OpenProcess time, so the handle keeps working afterward.
  5. Always restores the original DACL -- on every path, including failures.

Concurrent force_dacl opens against the same target are serialized with a global lock, so a second rewrite cannot snapshot the first's temporary grant DACL as the "original" and restore to that (a TOCTOU that would otherwise leave the target permanently open).

What it cannot do. force_dacl only helps for a same-user target that hardened its own DACL. It cannot bypass a higher-integrity process, a process owned by another user, a process carrying an OWNER_RIGHTS SID, or a PPL/anti-cheat process -- those are integrity/kernel boundaries, not DACL ones, and the bypass fails cleanly with a descriptive error, leaving the target untouched.

force_dacl defaults to false; the invasive DACL rewrite never happens unless you explicitly ask for it.

This release was verified end-to-end on Windows: a same-user target self-hardens its DACL (deny PROCESS_VM_READ), memtool is denied without force_dacl, succeeds with force_dacl=true, and is denied again afterward -- confirming the original DACL is restored.