Field guide

Analyzing binaries with your own AI agent

This guide shows a visitor how to point their own AI agent — Claude Code, Codex, or a claude.ai custom connector — at the helloscope MCP server, read the same real machine artifacts the museum reads, and replicate the analysis for themselves. The worked example is a question the museum already answers: "why is hello smaller than true?"

Everything below was produced by actually calling the server (User-Agent: helloscope-doc). The request/response shapes are copied from live output, not invented.


1. What this is

Behind one authless URL —

https://helloscope-live.iunknown.workers.dev/mcp

— sits an MCP server that exposes two things:

The honesty contract. These tools are built to be cited, not paraphrased. The discipline that the museum's own published exhibits follow, and that your agent should follow too:


2. Connect your agent

(a) Claude Code

claude mcp add --transport http helloscope https://helloscope-live.iunknown.workers.dev/mcp

Then just ask: "Using the helloscope MCP server, look up the archived binaries and tell me libc's build-id." The archive_* tools need no join_session — they answer immediately. (join_session is only for driving the live room page; see the guide.)

(b) Codex

codex mcp add helloscope --url https://helloscope-live.iunknown.workers.dev/mcp

Gotcha, documented honestly. In an interactive codex session this works after you approve the tool once. But a non-interactive codex exec run cannot approve MCP tool calls — it will refuse the call unless you either (i) run interactively and approve, or (ii) bypass the approval/sandbox layer explicitly:

codex exec --skip-git-repo-check --dangerously-bypass-approvals-and-sandbox \
  "Using the helloscope MCP server, run archive_lookup and report the two build-ids."

This is exactly how the museum drives Codex as a clean-room fact-checker (below). The bypass flag is only safe here because the tools are read-only network calls — the server has no write surface a visitor can reach.

(c) claude.ai custom connector

In claude.ai → Settings → Connectors → Add custom connector, paste the URL https://helloscope-live.iunknown.workers.dev/mcp. It is an authless remote MCP server, so there is no OAuth step and no API key — the connector attaches and the eleven tools appear in the tool tray. Ask your questions in the chat.

(d) Raw curl (any agent, or just you)

The transport is JSON-RPC over HTTP. initialize mints an Mcp-Session-Id header you echo on every later request:

# 1. Handshake — capture the session id from the response header.
curl -sS -D - -o /dev/null -X POST https://helloscope-live.iunknown.workers.dev/mcp \
  -H 'Content-Type: application/json' -H 'Accept: application/json' \
  -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{
        "protocolVersion":"2024-11-05","capabilities":{},
        "clientInfo":{"name":"me","version":"1"}}}' | grep -i mcp-session-id
# → mcp-session-id: dcea25ff-f151-43f6-a5d0-16f183f9c35f

The initialize result identifies the server as helloscope-live 9.0.0 and returns an instructions string describing the two validation regimes. Every subsequent call adds -H "Mcp-Session-Id: <that id>". To end cleanly, DELETE /mcp with the same header (replies 204). Archive tools don't strictly need the session id, but sending it is harmless.


3. The eleven tools

tools/list returns eleven. Four are the stateless archive tools; seven drive the live room.

The four archive tools

Every one takes a build_id (from archive_lookup) and returns a JSON text block that includes provenance and build_id.

archive_lookup({query?}) — no arg lists the archived binaries; a soname or build-id summarizes one. Real (trimmed):

// archive_lookup({})
{
  "archive": "Reference Linux Archive",
  "provenance": {
    "origin": "Arch Linux glibc 2.43+r22+g8362e8ce10b2-2; debug package recovered by
               bit-exact reproducible rebuild (…)",
    "objdump": "GNU objdump (GNU Binutils) 2.46.0", "pyelftools": "0.33" },
  "binaries": [
    { "build_id": "8f271b1884fa6225b5ac96a6bcb7326a5f1f25ec",
      "soname": "ld-linux-x86-64.so.2",
      "counts": { "exports": 22, "fn_objects": 22, "sources": 15 } },
    { "build_id": "020d6f7c33b2413f4fe10814c4729dce1387f049",
      "soname": "libc.so.6",
      "counts": { "exports": 2956, "fn_objects": 2323, "sources": 1564 } } ]
}

archive_function({build_id, name, start_row?, max_rows?}) — a function's real disassembly as addr: bytes text rows under ── file:line ── DWARF group headers. Windowed (200 rows default, 500 cap). Real (trimmed):

// archive_function({build_id:"020d6f7c…", name:"exit"})
{ "name": "exit", "version": "GLIBC_2.2.5", "aliases": "exit@GLIBC_2.2.5 (default)",
  "addr": { "hex": "0x411c0", "dec": 266688 }, "size": 30, "type": "FUNC",
  "source_files": ["stdlib/exit.c"],
  "window": { "start_row": 0, "count": 7, "total": 7 },
  "listing": "── stdlib/exit.c:147 ──\nexit:\n  411c0: f3 0f 1e fa           endbr64\n
              411c4: 55                    push %rbp\n── stdlib/exit.c:148 ──\n
              411c5: b9 01 00 00 00        mov $0x1,%ecx\n … \n
              411d9: e8 32 fd ff ff        call 40f10 <erand48_r@@GLIBC_2.2.5+0x70>",
  "note": "… a run of rows with NO header carries no source claim (not 'no source'). …" }

archive_source({build_id, path, start_line?, end_line?}) — verbatim glibc source, numbered NNN→text, cap 400 lines/call. Real (trimmed):

// archive_source({build_id:"020d6f7c…", path:"stdlib/exit.c", start_line:145, end_line:160})
{ "path": "stdlib/exit.c",
  "lines": { "start": 145, "end": 150, "total": 150 },
  "source": " 145→void\n 146→exit (int status)\n 147→{\n
             148→  __run_exit_handlers (status, &__exit_funcs, true, true);\n
             149→}\n 150→libc_hidden_def (exit)",
  "truncated": true, "footer": "showing lines 145..150 of 150." }

archive_callees({build_id, name}) — outgoing control-transfer edges, honestly classified. Real (trimmed, puts):

// archive_callees({build_id:"020d6f7c…", name:"puts"})
{ "counts": { "direct": 3, "tail": 0, "into_body": 0,
              "internal_unresolved": 3, "indirect": 1 },
  "edges": [
    { "kind": "direct", "target": "0x915d0",
      "names": [{ "name": "__overflow", "version": "GLIBC_2.2.5", "default": true }] },
    { "kind": "internal_unresolved", "target_addr": 148048,
      "target": "0x24250 (inside no exported function; no name claimed)" },
    { "kind": "indirect", "target": "not statically resolvable (*0x38(%rax))" } ]
}

The six room-driving tools (one line)

join_session, get_agent_guide, get_state, apply_state, play_step, export_tour — these bind to and narrate the live room page. get_agent_guide is the one you want even offline: it returns the whole agent guide as Markdown (the ELF Atlas state grammar and the complete hello fixture facts). Read it before driving; see exhibit-001-elf-atlas/agent-guide.md for the full contract.


4. Worked example — "why is hello smaller than true?"

Here is the exact sequence an agent runs. Each step ends in a real citation.

Step 1 — get hello's numbers. Call get_agent_guide(). Its FIXTURE FACTS section states: build/hello is 16,984 bytes; .text (the compiled code) is 275 bytes, 68 instructions; and it imports exactly three libc functions — puts, __libc_start_main, __cxa_finalize. (Source: get_agent_guide.)

Step 2 — get true's numbers. The true room's fixture is a static asset you can fetch directly (no MCP needed):

curl -sS -H 'User-Agent: helloscope-doc' \
  https://helloscope-live.iunknown.workers.dev/rooms/true/true.json

Its file.size is 43,248 bytes; the .text section is 19,027 bytes; and the dynamic symbol table has 46 undefined (SHN_UNDEF) FUNC imports — 46 libc functions it calls, versus hello's 3. (Source: /rooms/true/true.json, file.size / sections[.text].size / count of dynamic_symbols with shndx == "SHN_UNDEF" and type == "FUNC".) The companion asset /rooms/true/imports.libc.json carries the resolved libc bodies of those imports. (hello's own live page at / embeds its fixture inline rather than serving it as a separate file.)

Step 3 — see what the extra code is. The 46 imports name the difference: setlocale, dcgettext, bindtextdomain, textdomain, nl_langinfo, mbrtoc32, __printf_chk, strerror_r … — locale, message translation, and formatted-output machinery that a one-line printf("hello, world") never touches. Pull one to size it:

// archive_function({build_id:"020d6f7c…", name:"setlocale", max_rows:6})
{ "name": "setlocale", "addr": { "hex": "0x35920" }, "size": 1902,
  "source_files": ["locale/localeinfo.h", "locale/setlocale.c"],
  "window": { "total": 389 } }
// archive_function({… name:"dcgettext"}) → size 19  (a thin shim to __dcgettext)

setlocale alone is 1,902 bytes — nearly seven times hello's entire 275-byte .text. (Source: archive_function, size field.) true links against 46 such functions; hello links against 3.

Step 4 — the punchline. true's .text section (19,027 bytes) is by itself larger than hello's whole file (16,984 bytes). The size gap is not overhead — it's program: true is the GNU coreutils true, carrying argument parsing, --help/--version, and full internationalization; hello is one puts call.

A good final answer (house style, claim → source)

hello is 16,984 bytes and true is 43,248 — about 2.5× larger (both from the fixtures: get_agent_guide FIXTURE FACTS for hello, /rooms/true/true.json file.size for true). The gap is real code, not padding: true's .text section is 19,027 bytes (true.json sections[.text].size) — larger than hello's entire file. It shows up in the imports too — true pulls 46 libc functions (count of SHN_UNDEF FUNC entries in true.json dynamic_symbols) against hello's 3 (get_agent_guide). Those extras are internationalization and safe I/O: setlocale is 1,902 bytes, dcgettext a 19-byte shim into __dcgettext (both from archive_function size, libc build-id 020d6f7c…, glibc 2.43+r22+g8362e8ce10b2-2, GNU objdump 2.46.0). hello is small because it does almost nothing; true is a real coreutils program with a locale-aware, option-parsing front end.

Every clause names the tool it came from. That is the whole discipline.


5. Replicate on hello itself

The canonical museum question is: what does .text's call 1030 <puts@plt> actually reach? Ask your agent to trace it end to end using only the tools:

  1. get_agent_guide() — confirms main at 0x1139 calls puts@plt at 0x1147, and that puts lives in libc.so.6, build-id 020d6f7c….
  2. archive_function({build_id:"020d6f7c…", name:"puts"}) — the real puts body at 0x83fc0, 542 bytes, under libio/ioputs.c line headers.
  3. archive_callees({build_id:"020d6f7c…", name:"puts"}) — where puts goes next: three direct edges (__overflow, and the private lock helpers __lll_lock_wait_private / __lll_lock_wake_private), three internal_unresolved targets (addresses only, no name claimed), and one indirect call (*0x38(%rax) — a vtable dispatch, not statically resolvable).
  4. archive_source({build_id:"020d6f7c…", path:"libio/ioputs.c", …}) — the C the addresses map to.

A fresh agent given only the MCP URL has answered exactly this question and had every claim adversarially fact-checked against the same tools — the archive's own Stage-2 cold-agent gate. Your agent should reach the same edges, because they come from the same byte-canonical objdump rows.

Note the honest edge case worth teaching your agent: in exit's listing, objdump labels its one call <erand48_r@@GLIBC_2.2.5+0x70>, but archive_callees reports that target as internal_unresolved ("inside no exported function; no name claimed") — because the address lands past the end of erand48_r, not inside it. The tool is deliberately more honest than the raw objdump label. Never let an agent quote erand48_r here.


6. The honesty gate

Any answer built from these tools is checkable by the same tools — that is the point. To adversarially fact-check any claim (your own agent's, or the museum's):

  1. Dispatch a second, fresh agent (a different model is better — the museum uses Codex via codex exec --dangerously-bypass-approvals-and-sandbox) with only the MCP URL and no local file access.
  2. Its job is to refute, not confirm: for every numeric, structural, or behavioral claim, try to disprove it with tool output. Verdict per claim: CONFIRMED (output matches), REFUTED (output contradicts — quote it), or UNVERIFIABLE (no tool can establish it).
  3. The gate is zero REFUTED. Anything UNVERIFIABLE (ASLR load addresses, lazy-vs-eager binding timing, unnamed IFUNC targets) must be rephrased as not established or dropped.

This is not hypothetical: it is precisely how every published exhibit in this repo was validated before shipping (see .claude/skills/walkthrough/SKILL.md, Phase 4). Because the archive is byte-canonical, two agents that cite honestly reach the same numbers.


7. Limits & provenance