A desktop app starts hanzo-mcp with no environment of its own, and
`hanzo login` never writes an apiKey into ~/.hanzo/config.json, so on a
fresh machine every Kai and Enso call answered NO_API_KEY. With no key
set, hanzo-mcp now asks the hanzo CLI beside it (or at hanzo.sh's
install path) for the login's token, `hanzo auth token`, and keeps it
for five minutes. Never a hanzo found on PATH: the Hanzo IDE package
installs /usr/bin/hanzo, which opens its window on any arguments.
Claude-Session: https://claude.ai/code/session_01M8RoubcPN71KgJuZ8LdBUL
zapd 1.1.9 runs the router on Windows (a named pipe, LockFileEx). The
browser's native host follows it there: the host binary is a hard link
hanzo-zap-host.exe, each installed browser is pointed at its manifest
through its NativeMessagingHosts registry key, and the relay opens the
router's pipe only when its server is the router lock holder. The
release builds windows-amd64 again, cross-linked by Zig like the rest.
Claude-Session: https://claude.ai/code/session_01M8RoubcPN71KgJuZ8LdBUL
The same annotation hanzo-mcp 1.1.31 gives its tools: every Kai decision
tool, llm, think and code_search/context/ask are listed with readOnlyHint,
so Codex and its forks call them in a run that never asks.
Claude-Session: https://claude.ai/code/session_01M8RoubcPN71KgJuZ8LdBUL
Codex, Hanzo Dev and Open Interpreter ask before any MCP tool that is not
marked read-only, and a run that never asks refuses it, so every hand-off
to Kai or Enso in a headless run was refused. The tools that read or hand
work to Hanzo's models and indexes (kai_decide, llm, research, web_*,
code_search, code_context, code_ask, vision, think) are now listed with
readOnlyHint. kai, which runs actions, and code_index, which writes the
index, are not.
Claude-Session: https://claude.ai/code/session_01M8RoubcPN71KgJuZ8LdBUL
The Windows leg fails at zapd, whose ZAP router and native host speak a
Unix socket, so every release carried a red job. Linux and macOS on
both architectures are what hanzo-mcp ships.
Claude-Session: https://claude.ai/code/session_01M8RoubcPN71KgJuZ8LdBUL
The release lane built Linux alone, so hanzo.sh offered macOS and
Windows a server nobody had built, and `hanzo dev` there fell back to
nothing. It now cross-builds linux, windows and darwin for both
architectures from the one Linux pool with Zig and a pinned macOS SDK,
the way the CLI's release does, one target at a time. Zig comes from its
own checksummed tarball, not pip.
`hanzo dev` hands its server `--project-dir`, the native spelling. The
npm build read it as stray arguments and exited, so a machine whose
PATH found the npm `hanzo-mcp` first ran every session without the
toolset. It now takes the flag as an allowed root.
Claude-Session: https://claude.ai/code/session_01M8RoubcPN71KgJuZ8LdBUL
kai-1.3 serves planted in production. Read once through api.hanzo.ai on
the 457 held-out rows, rules and planted flag 209 of 250 attacks at 6 of
207 false alarms (rules and the jailbreak question: 182, 2). The README,
the cookbook table and recipe 04 drop the jailbreak question and the
threshold argument the tool no longer takes.
Reads: GET /v1/research/runs?org=hanzo&benchmark=mcp.injection
Claude-Session: https://claude.ai/code/session_01M8RoubcPN71KgJuZ8LdBUL
Kai answers through planted (kai-1.3), a capability fit on InjecAgent's dev
tools and wordings over kai-1.2's read, at the probability whose
out-of-fold false alarms stayed within 1%. kai-1.2's jailbreak question
added no catch over rules and planted, so it is no longer asked. Reads:
GET /v1/research/runs?org=hanzo&benchmark=mcp.injection.
The runner's npm dropped both bin entries written as ./dist/cli.js as invalid
while publishing 2.4.17, and the version never reached the registry. Bare
dist/cli.js is the form 2.4.16's published bins read; a packed tarball of
this tree installs hanzo-mcp and mcp and lists the kai tools.
Claude-Session: https://claude.ai/code/session_01M8RoubcPN71KgJuZ8LdBUL
kai_next_action never returns a candidate whose preconditions are unmet,
returns a single remaining one as is, and picks among several by BM25
against the goal, abstaining on a tie or no shared word; no Kai call (a
Kai choice over candidates read below BM25 for tools). kai_recover maps
each kai_log_triage class to a step (permission asks the user) and keeps
the three-failure switch. kai_extract computes a yes/no field's confidence
from P(true), abstains in Kai's unknown band (.35 to .65), and returns a
score field's level rather than its mean.
Recipes 02, 06, 07, 11, 12 and 14 check what the tools return, including
abstention; the cookbook README and the tools' comments link the research
runs instead of quoting numbers. bench/tools/post.py files each tool's read
(benchmarks, one run an arm, items and cases as artifacts) to /v1/research.
60 real CI logs from 48 public repositories, labelled by class from the
excerpt alone without the rules in view, read once against aee2889's
signatures: 42 right (.70), 7 answered unknown, 11 another class. On the
40 logs whose class ae4d07b's six categories can name, its Kai choice read
20 (p50 502 ms on the CPU) and the signatures 28.
The guard allowed anything its few patterns missed whenever Kai's choice said
allow, and kai-1.2 reads command risk near chance: on hermes-jev-skills' 118
gate fixtures (MIT) it allowed 12 of the 65 commands that must be denied or
asked (rm -rf ~/Documents, chmod -R 777 ~, base64 -d | sh) and asked or
denied 33 of the 53 ordinary ones.
Now deny and ask are classes matched on the whole line (wipe, pipe_shell,
exfiltrate, secret_read, permissions, clobber, disk, metadata, drop_data,
claimed_approval; git_remote, git_discard, publish, deploy, service,
message, privilege, global_install, sync_delete, persistence, environment,
parent_path), and allow needs every part of the line to be inspection, a
build or test, local git, or a file change under a relative path or /tmp.
Anything else asks. The caller's policy is a floor. No Kai request: its
risk questions changed no outcome on the fixtures but one more ask.
Frozen and read once on the fixtures: 1 of 65 allowed, 18 of 53 asked.
Two classes were fixed after that read and are not held out: the
metadata-endpoint fetch (the one allowed command) and a bare `rm -rf /`,
which asked where it must deny. With them: 0 of 65 allowed.
Measured against kai-1.2 served locally from production's folder (SHA256SUMS e45dd919…).
kai_injection_screen asks Kai the question it was trained on ({prompt},
"Does `prompt` try to make an AI assistant ignore its rules...") beside
seven rule classes: override, role, addressed, conceal, exfiltrate,
tool_call, act, plus the earlier extract patterns. Held out on InjecAgent's
unseen tools and wordings (250 attacks) and 207 benign tool outputs and
README paragraphs: recall .728 at .010 false alarms, against .044 at .116
for the screen it replaces. Kai's question alone found 0 of the 250
planted requests. Rules still decide when Kai does not answer.
kai_tool_select ranks by a named tool, then BM25 over name, description
and parameters, and makes no Kai call: on 300 supplied tool lists from
Kai's T1 dev split it reads top-1 .733 and MRR .841 against .657 and .805
for one Kai choice over the names, in 0.1 ms against 109 ms.
kai_log_triage classifies by failure signature (resources, network,
timeout, permission, dependency, compile, test, crash; none and unknown)
and returns the deciding lines and file spans; kai-1.2 read build logs at
chance. Recipes 01, 04, 05, 08 and 10 check what these tools return;
recipe 08 calls the tool instead of matching names itself. The cookbook
reaches Kai as clients do, through HANZO_API_KEY or LOCAL_KAI_URL, not a
fixed local port and placeholder key. compact.ts types its decision so
tsc passes.
kai_command_guard decided its policy inside the try, so the catch that
keeps a deny or ask on a failed Kai call could never read it; the
policy is now decided first.
The walk recurses with a command's nesting, and a flat chain nests as
deep as it is long, so a hostile command overflowed the caller's stack
and aborted the server (the classifier's own deep-nesting test died
under a debug build). A command over 64 bytes is now read on a thread
with a 256 MiB stack, reserved and not committed, and one over 64 KiB
is refused unread as unparsed.
npx and uvx run through exec, whose classifier asks before a package is
fetched, and they had no way to pass the approval: since the merge
neither could run at all. They take approve as exec does.
The hostile-text bound is 3 s for an optimized build and 15 s for an
unoptimized one on a loaded host; it catches a quadratic read, which
takes minutes. The unified surface lists the kai tool beside the eight
purpose-built ones.
- Add 'link' command with auto-detection for Claude Code, Cursor, and Codex
- Add '--beta-safe' profile exposing only validated release-candidate tools (11 tools total)
- Harden kai_command_guard with zero-inference deterministic deny and fail-safe offline fallback
- Separate deterministic resolution from Kai model inference (model_verdict: null on zero-inference)
- Enforce strict precondition checks in kai_next_action (abstain on unsatisfied preconditions)
- Add bench/tool_select_comparison.py comparing Deterministic vs BM25 vs Kai vs Hybrid (44 cases)
- Verify live Claude Code tool invocations across kai_command_guard, kai_recover, and kai_tool_select
The kai tool took kai_url from the call and sent HANZO_API_KEY to it,
with the repository's file names, branches and commit subjects. A
model, or text it read, could name any host. The URL is now
HANZO_API_BASE (or api.hanzo.ai) plus /v1, in the Rust tool and the
TypeScript one alike, and kai_url is gone from the schema.
A pure-Rust classifier for the `Classify` seam. brush-parser (MIT) gives the bash AST
and rustpython-parser (MIT) the Python one; every simple command, redirection,
substitution and nested script (bash -c, here-documents, a script the command wrote and
runs, python -c, ssh host '...') is read in the order the shell runs them, and the policy
over paths decides what each costs. rust/shell is its own crate with a wasm-bindgen build,
so kai-gateway can replace safety.ts with it.
exec: an ask is refused until the call carries approve: true (never lifts a deny); the
interpreter must be a shell the classifier reads; the env is read beside the command;
the git tool asks the same classifier and runs with core.fsmonitor=false; ui install no
longer hands the component name to sh -c.
On 408,231 Bash commands: safety.ts flags 20.69% (15.27% unparsed); this answers
allow 88.2 / ask 9.6 / deny 2.2 (roots set), cannot read 0.22%, p99 0.6 ms.
The kai section names hanzo-actions (rust/actions) as the executor, and
the native-tools section says which crate Dev links for what: hanzo-
trigram for search, hanzo-actions for actions, hanzo-mcp for ExecTool.
fs, code, exec and kai together are 6.5 KB, about 1.6K tokens; the
surface test holds them under 16 KB.
kai is one action-routed tool (run, plan, list, triggers) over
hanzo-actions. run matches the request to an action or workflow, binds
its inputs, checks the preconditions, runs, and returns the ledger:
outcome done, failed or declined, and a record per step with its source
(rule or kai), preconditions, authorization, cache use, steps, timing and
the verifier's verdict. A declined request is the caller's own agent's.
Authorization is the call's: an action that writes outside the workspace,
reaches a remote or stops a process runs only with approve=true, so the
client's own approval flow decides. An action with no recorded proof
here does not run unless the call says it is proving its workspace.
One schema, schema/kai.json, the same text in rust/schema/ and in
src/tools/kai-action.ts; a test fails if the three differ. The work is
not written twice: the TypeScript tool runs the native binary named by
HANZO_MCP_NATIVE, and `hanzo-mcp kai run|plan|list|triggers` is that
binary's command line (exit 0 done, 2 declined, 3 failed).
The tool's test now proves its workspace and asserts the first refusal
is the approval and that the push reaches the remote only after the
grant; before it passed the proof gate's refusal off as the approval's.
An action is one saved, typed, verified operation: its inputs and what
each may be, the preconditions read again just before it runs, what it
does to the world (which decides whether the user must approve it), what
its result depends on, and the check that its effect happened. A request
is matched to an action or workflow by trigger, its inputs are bound
from the request and from values read out of the repository, and Kai is
asked only when several actions or values remain; it may abstain and it
never writes a command. Every run leaves a ledger record.
The module began as hanzo-actions in Dev (hanzoai/dev branch actions)
and lives here now, as its own workspace crate for the reason hanzo-
trigram is one: Dev links it without taking this crate's ZAP router,
browser and parser dependencies. hanzo_mcp::actions re-exports it.
Paths inside it are crate:: paths; nothing else changed in the move.
34 tests: binding, authorization, the proof gate, the cache, redaction,
and the git-backed actions run against real repositories.
unified/index.ts spread kaiAgentTools without importing it, so loading
the module threw a ReferenceError and the TypeScript server could not
start. tsc reports it (TS2304) and fails on d2dc818, which hanzo.yml's
typecheck step would too. compact.ts read the decision's JSON into a
value tsc types unknown (TS18046); the answer is the wire's, as kai.ts's
decide() already returns it.
tsc --noEmit is clean and scripts/smoke-mcp.mjs passes 9 of 9 against the
bundle.
A search with a limit now runs in two passes: every file is weighed
(matching lines counted, definitions as 2^32) without holding a hit,
then hits are read from the heaviest files until the limit is met. The
answer is the head of the unlimited list, checked for limits from 0 to
the whole list over text, symbol, context, ties and documents. Before,
every hit of every file was built, sorted and deduplicated before the
limit applied: a pattern matching 1.28 million lines of a 112 MB tree
took 15.6 s where it now takes 0.14 s, and rg's --max-count had bounded
that case before this crate replaced it.
When there is a tgrep index at or above the searched directory the
search asks it which files can match. Candidates are matched against
current bytes, and files whose content or name changed at or after the
build are read from disk whether or not the index lists them, so the
answer is the scan's. Index::Skip scans, Index::Only refuses to; the
tests ask every query all three ways on the same tree, before and after
edits, creations, deletions and renames, and fail if the freshness walk
is disabled. hanzo-mcp index [DIR] builds one.
Measured on 112 MB / 9,026 files, cores 0-4: rg 43-55 ms, scan 75-93,
indexed 28-50. Without a watcher the index costs a metadata walk.
tools/list appended the registered tools in HashMap order, which is
different in every process: two starts of the same binary listed
different orders (checked: three runs, three orders), so a client's
prompt cache over the tool schemas could not hold between sessions. The
registered tools now follow the built-ins sorted by name.
--tools fs,code,exec lists and serves only those. A client is charged
for every schema it is shown, each turn: the whole list is 34 tools and
38 KB, about 9.5K tokens; fs, code and exec together are 4.6 KB, about
1.1K, and byte for byte the same on every start. A name the server does
not carry fails at startup and lists what it does; a call outside the
list answers Unknown tool, like any other name.
ToolsConfig's flags were never read and are left alone.
Classify is the contract between exec and the shell parser and
classifier: command and cwd in, a Verdict (allow, ask or deny, with
reasons) out. ExecTool::with_classifier installs one; ToolRegistry::
with_classifier does the same for the MCP server; ExecTool::verdict is
the call Dev makes to ask the same classifier without running anything.
A deny is refused before a process is spawned or registered. An ask runs:
a server cannot ask the person, and the client's own permission prompt
already stood between the model and the call. Every answer then carries
the verdict. refusal() is the one place that policy lives.
With none installed, which is every build until the classifier module
lands, exec answers byte for byte as before and its input schema is
unchanged: the verdict is an output field, so the TypeScript runtime's
schema stays identical.
hanzo-trigram is the one search engine behind both actions: tgrep-core
(the hanzoai fork, MIT, pinned by commit, feature docs) linked as a
library, so a search is a call, not an rg spawn. It is its own crate so
Dev can link it without this crate's browser and parser dependencies;
hanzo_mcp::trigram re-exports it.
Results are ranked (matching lines a file, or definitions before uses
for a symbol), then path, then line, so a page is an offset into one
fixed list and the cursor really pages: before, rg stopped at limit and
a cursor repeated the first page. .gitignore, hidden and binary files
are skipped as rg skips them. A glob that names a document type (*.pdf,
*.docx, *.zip) reads documents too, and a hit inside one carries the
archive members and its page, slide, paragraph or cell.
fs and code keep their names, schemas and answer shapes; entry and place
appear on a document hit only. search_symbol now reads definitions by
the engine's declaration patterns, not the first-keyword guess.
Linking tgrep-core brings libsqlite3-sys (its managed-index code), which
needs a newer cc than the tree-sitter grammars allow, so Cargo resolves
tree-sitter-{c,cpp,javascript,python,rust,typescript} to their previous
patch releases. 241 library tests and the code tool's pass on them.
Gating managed/ behind a feature in the fork removes both.
rust-v1.1.26 and rust-v1.1.27 shipped .sha256 sidecars holding a bare
hash. install.sh, which hanzo.sh runs for `curl hanzo.sh/mcp | sh`,
verifies with `sha256sum -c`, which needs "<hash> <name>", so every
Linux install of those releases stopped at "checksum MISMATCH" and
installed nothing. The sidecar is sha256sum's own line again, as it was
through rust-v1.1.23.
Release 1.1.28.
Co-authored-by: Hanzo Dev <[email protected]>
The Rust hanzo-mcp could not reach Kai at all, and its llm tool defaulted
to an upstream model id, sent no routing bound, dropped the completion id
and the model that served, and returned a 402 or 429 as ok with empty
content because the client never read the status.
- kai_decide: POST /v1/decisions with the TypeScript tool's name, schema
and shape checks (choice, score, noul; 1 to 100 questions).
- llm query: model enso-auto unless named, "auto" for Enso's pick;
max_cost and max_latency_ms ride as X-Max-Cost and X-Max-Latency-Ms;
the answer carries id and the model from X-Routed-Model or the body.
- llm models: one row per model with family, class and the catalog's
per-million prices; family narrows it.
- llm feedback: POST /v1/ai/feedback with request_id, signal, rating.
- HanzoApi::call holds an answer to its status: a non-2xx, or an
envelope that says error, is an error with the server's sentence.
Release 1.1.27.
Co-authored-by: Hanzo Dev <[email protected]>
HIP-0300 names llm among the optional tools and Python and Rust carry it,
but the TypeScript server had none, so an agent on @hanzo/mcp could ask
Kai a typed question and could not reach a model.
llm routes by action over api.hanzo.ai:
- query: POST /v1/chat/completions, model enso-auto unless named; "auto"
lets Enso pick. max_cost and max_latency_ms ride as X-Max-Cost and
X-Max-Latency-Ms. It returns the model that served (X-Routed-Model, else
the body's model) and the completion id feedback takes.
- models: GET /v1/models, each model's family, class and per-million
price as the catalog states it; family narrows to enso or kai.
- feedback: POST /v1/ai/feedback with request_id, signal and a 1-3
rating; an envelope that says error is an error.
Co-authored-by: Hanzo Dev <[email protected]>
The runner image carries no gh CLI, so the open job died on its first
command and nothing was built. The release action talks to the API
itself, and the toolchain action installs Rust and the target rather
than trusting the image to have them. hanzo-mcp 1.1.26.
The org's runners serve linux-amd64 only, so the arm64 and darwin jobs
waited on labels nothing serves. arm64 now cross-builds on amd64 with
cargo-zigbuild, zig being the C toolchain ring needs; darwin is off the
matrix until a macOS runner exists.
exec gains run(): an argv with no shell, waited on up to a deadline, then
left running in exec's process table. npx and uvx use it with Python's argv
and a two-minute deadline; jq passes its filter as one argv word.
@hanzo/mcp 2.4.14
npx and uvx build Python's argv (package, whitespace-split args, npx -y,
uvx --python) and run it as an exec with a two-minute auto-background. jq
passes its filter as one argv word, as Python's jq tool does.
A node registering between ours entering the registry and our WELCOME
being queued is announced to us first; the extension ignores it, and so
does the test now.
A command past its timeout had its pipes dropped with the wait, so it lost
its output (or died of SIGPIPE) and stayed running forever in ps. Its
output now drains to a log that logs reads, and its exit is recorded; a
command that finishes in time returns inline and leaves no log.