feat: add Grok Build harness adapter - #12
Conversation
Grok 1.0.5 spawns $SHELL by absolute path and wraps run_terminal_command in a __grok_user_cmd envelope. Point SHELL/GROK_SHELL at the PATH shim, unwrap the payload after --, keep login snapshots local, and deny the native file tools so they cannot act on the operator's machine.
Resolves the CHANGELOG.md conflict: both sides added an entry under [Unreleased] / Added, so both entries are kept — the Grok Build adapter first, then docs/HOW-IT-WORKS.md from main. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PuwaXDJut6CorENbW1KHPk
|
Thanks for this — I installed grok 1.0.5 and verified the whole adapter against a live target (an Alpine container via Confirmed working
Blocking 1 — the
|
| rule | blocks native tool | also blocks in the shell |
|---|---|---|
--deny Read |
read_file, list_dir |
cat |
--deny Write |
write |
echo > file |
--deny Edit |
search_replace |
sed -i |
--deny Grep |
grep |
— |
So the adapter denies the file tools, tells the model via grokExecGuidance to use the shell instead (read cat -- FILE, write cat > FILE <<'EOF', edit sed -i), and then the same rules block exactly those. Measured through reach grok against the container: cat /work/remote_marker.txt came back "denied by the permission policy". The adapter can run hostname, but cannot read, write or edit anything on the target by any route.
A mechanism that does work in the TUI is an agent profile — --agent <file.md> with disallowedTools:. Verified: it removes read_file, search_replace, list_dir and grep while leaving shell cat working. Worth noting it also catches write, a local-filesystem tool that is in grok's live tool list and in none of its documentation.
(Also: --agent-profile from the docs does not exist in the 1.0.5 binary; the flag is --agent.)
Blocking 2 — reach harness verify grok can never pass
Three separate bugs. Because cmdGrok calls guardHarnessSeam, this means reach grok does not launch at all without -force:
grokPreparewritesbase_url = "http://127.0.0.1/unused". A model's ownbase_urloverridesGROK_CLI_CHAT_PROXY_BASE_URLcompletely — confirmed with two logging HTTP servers on separate ports, where onlybase_urlwas ever dialled. The probe therefore talks to nothing and times out after 2 minutes. Omittingbase_urllets the model resolve through the cli-chat-proxy, which the env var points at the mock.- The mock's chat-dialect tool call omits
description, which grok'srun_terminal_commandrequires:Failed to parse arguments for tool run_terminal_command: missing field 'description'.pickChatToolneeds a per-tool argument shape rather than one generic{"command": ...}. baseProbeEnvnever pinsREACH_HOME.grokEnvreplacesHOME, so reach's shim looks for the session store under the throwaway home, finds no session, and the seam gets reported as broken when it is fine.geminiEnvhas the identical shape, so this one predates this PR and likely affects the gemini probe too.
With those three fixed the probe reaches verdict: ok — tool output reports hostname "REMOTEBOX", the target.
Smaller points
docs/harnesses/grok.mdsays the shim directory is prepended toPATH"as a fallback" — PATH alone is never consulted, so it is not a fallback.- README and
ARCHITECTURE.mdlist Grok as verified. The seam is verified; the adapter as a whole is not usable until the deny-rule issue is resolved. - The probe passes
--tools run_terminal_command, but grok's documented filter ID isrun_terminal_cmd. It appeared to work, but an unknown name may simply no-op the allowlist — worth confirming, since a silently-ignored allowlist is not the isolation the probe intends.
Next
I have pushed a merge commit resolving the CHANGELOG.md conflict (both sides added under [Unreleased] / Added; both entries kept), so the branch is mergeable again. I will follow up with the three probe fixes and the agent-profile change, and re-run the end-to-end test.
Removing the local file tools with --deny also removed the shell's file
access. Grok classifies a shell command that reads a file under the same
permission prefix as read_file, so --deny Read denied `cat`, --deny Write
denied `cat > file`, and --deny Edit denied `sed -i` — precisely the commands
the adapter's own guidance tells the model to use once the native tools are
gone. Measured against a container target, the session could run `hostname`
and could not read, write or edit a file on the target by any route.
The tools are now removed by a generated agent profile instead, whose
disallowedTools list takes them out of the model's view without teaching the
permission layer anything about shell commands. The list includes `write`,
which grok advertises in a live session and documents nowhere; a list built
from the documentation alone leaves the agent a local file writer. The
profile must exist before grok starts: --agent pointing at a missing path is
not an error to grok, it falls back to the default agent with every local
file tool enabled and says nothing, so a write failure is fatal here.
The seam probe could not reach a verdict at all, which meant `reach grok`
refused to launch without -force. Its config pinned the model's base_url,
which overrides GROK_CLI_CHAT_PROXY_BASE_URL outright, so the probe dialled a
dead address and timed out instead of reaching the mock. The mock sent
{"command": ...} for every harness and grok's run_terminal_command rejects a
call with no description. And a probe that replaces HOME left the shim
looking for the session store under that throwaway home, reporting a working
seam as broken — REACH_HOME is now pinned for every probe, which fixes the
same latent fault in the gemini probe.
Verified end to end against a container target and a real ssh target: the
probe returns the target's hostname, and a session reads, creates and edits
files on the target with nothing written locally and a host-only path absent
from inside the session. What is not yet measured is the TUI specifically;
docs/harnesses/grok.md records that and how to check it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PuwaXDJut6CorENbW1KHPk
main gained the target-first dispatch (`reach <target> <command>`), which changes what this branch had to do in three places: - dispatch now returns an exit code rather than calling os.Exit, so the grok case follows the other harnesses. - grok is added to knownCommands. Without it a bare `reach grok` is read as a hostname rather than a command, which TestDispatchAndKnownCommandsAgree catches. - the README's "then start your agent" list is gone, replaced by the target-first quick start; the Grok row stays in the harness table, now describing the agent profile rather than the deny rules it replaced. CHANGELOG keeps both sides: the Grok entries alongside main's target-first, --untrusted removal and fixes. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PuwaXDJut6CorENbW1KHPk
wastedassign: the empty string `command` was initialised to is never read, both branches assign over it. The unwrap's own ok already expresses the choice, so the branch collapses. gosec G602: the analyser cannot follow the `i+1 >= len(args)` guard into the index that follows it. Slicing the tail once and indexing that makes the bound explicit to the analyser, and reads better besides. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PuwaXDJut6CorENbW1KHPk
|
Merged — thank you, @alexzhang1030. This is a genuinely good piece of reverse engineering, and I want to be specific about what held up, because it was the hard part. Every claim you made about the seam was correct when measured against a live grok 1.0.5:
Working out that shape from a stripped binary is not easy, and getting it right to the argument is why the rest was a matter of fixing rather than rethinking. Two things needed changing before it could ship, both only findable by running it: The The seam probe could not reach a verdict, which meant Verified end to end against a container target and a real ssh host: the probe returns the target's hostname, and a session reads, creates and edits files on the target with nothing written locally and a host-only path correctly absent from inside the session. One thing deliberately left open, recorded in Separately, your PR surfaced a bug in reach itself that had nothing to do with grok: every harness launcher reported "not installed or not in PATH" for a binary found through a relative Thanks again — the seam research is the part that could not have been guessed, and it was right. |
Summary
Adds
reach grokso Grok Build (verified 1.0.5) runs shell tools on the session target instead of the local machine.Grok does not walk PATH for bash. It spawns
$SHELLby absolute path and wraps eachrun_terminal_commandin a__grok_user_cmdenvelope (-O extglob -c '<envelope>' -- <command>). Login-environment snapshots ($SHELL -lc) stay local; the shim unwraps the payload after--and runs only that command on the target. Native file tools (read_file,search_replace,list_dir,grep) still hit the local disk, so they are denied at launch and subagents are disabled.Test plan
go test ./cmd/reach/ ./internal/harnessprobe/(envelope unwrap + snapshot classification)reach grokagainst a live session (reach upthen a remotehostname)reach harness verify grokonce a session target hostname differs from localmake conformanceon a machine withgrokinstalled (SHELL-wrapper probe)