Added
-
direct_workspace_mutationondelegate, behind an operator opt-in — awrite/yoloagent can
editworking_diritself, with live terminal access there, instead of the isolated worktree. This is for
work whose product is the effect on the tree rather than a diff: installing dependencies, running local
tooling, letting an agent see its own side effects. Contributed by
@Artemonim in #21.It gives up everything the sandbox provides — the clobber and concurrent-edit guards, symlink containment,
the committed-HEADstarting point, and the diff itself — so the run leaves no record of what it changed
and a failure may leave a partial one. The party asking for that is also the party least able to judge it,
so a request never suffices on its own. An operator has to enable
allow_direct_workspace_mutationin config, and the directory has to be on thetrusted_workspaces
allowlist: a per-calltrust_workspace=truedeliberately does NOT qualify, because a caller that can set
it could otherwise authorise its own unsandboxed writes and the allowlist would decide nothing.
working_dirmust be named explicitly rather than inherited from the server's own directory, the call
must not be nested inside another delegation, andproposecannot use it at all.Each admitted run logs before launch and again on completion, and the result carries
direct_mutation=trueso a reader can tell the absent diff means "never captured" rather than "nothing
was written". That record is best effort: it goes through the ordinary structured logger, whose
stderr sink is asynchronous so a host that never drains its pipe cannot freeze the event loop, which
means the record can be dropped under log saturation or lost if the process dies before it is written.
docs/security.mdsays so plainly and points an operator who needs a durable trail at collecting stderr
or enabling persistence, both of which live outside this process. Refusing to launch unless the write was
confirmed was built and removed; the reasoning is recorded indocs/security.mdrather than the code.The nesting condition is documented as defence in depth rather than a boundary. Depth crosses the process
boundary in an environment variable, and anything able to spawn a nested Rutherford controls that child's
environment, so it stops an accident rather than a hostile agent — which, perdocs/security.md, gains
nothing here it did not already have, since awrite/yoloagent was never OS-jailed.
Changed
-
FastMCP is bounded at the major, and the dependency set users actually resolve is now tested. The
pin wasfastmcp>=3.3with no ceiling. CI installs withuv sync --locked, but the lock is not shipped
and the published install instructions carry no constraint, so a freshuvx rutherford-mcp-server
resolved fastmcp 4.0.3 while the lock held 3.3.1 — every release so far ran on a dependency major its
own CI never executed. It worked, with one observed regression: under 4.0.3 a tool error writes a
rich-formatted, non-JSON line to stderr, breaking the one-JSON-object-per-line contract the structured
logger maintains deliberately. The forward risk is larger, because the server calls
mcp.run(transport=…, show_banner=…)with keywords a major is free to rename — a failure that lands at
boot, on every install, with a green build.The bound claims only the major that has been run, and the lock now matches what a fresh resolve picks,
so the two are no longer describing different software. A new CI job resolves unlocked and boots the
built wheel over stdio, which is the only check here that exercises what ships. -
The ACP SDK pin moves to 0.12.1, and Dependabot now tracks it through the
uvecosystem. The
release is a much larger change than its patch number suggests: it deletes the pluggable dispatcher,
queue and state-store layer outright and drops four keyword arguments fromConnection.__init__.
None of that reaches this package, which drives the SDK throughspawn_agent_processrather than
assembling a connection by hand, so no removed symbol is named here.What made the bump acceptable is that
_deserialize.pyis byte-identical to 0.12.0 — the
salvage-instead-of-reject behaviour that is the real hazard of this dependency, and the reason the pin
exists, is unchanged, so the guards written against it still hold.Connection.closealso got safer:
it rejects pending requests first and moves the task shutdown into afinally, while still latching on
an already-closed flag, which is the property the non-cancellable teardown stage depends on. The
regenerated schema is the part to watch next time — fouridfields became required, the content-block
and config-option unions gained discriminators, and several openstrfields narrowed toLiteral
unions, including the config-option category that the model and effort channels read.The Dependabot ecosystem changes from
piptouvbecause thepipecosystem updates the manifest
without regeneratinguv.lock, and CI installs with--locked. Every dependency PR therefore failed
all nine matrix cells on a stale lockfile rather than on anything about the dependency. -
The ACP SDK is now pinned to 0.12, and read as an untrusted source rather than a validating one. The
0.11 pin was raised after checking what actually changed: the protocol version is unchanged, both model
channels are intact, and one unused public name went away. What did change is deserialization, and it
changes what the library promises rather than what it exposes. A field the SDK cannot parse is no longer
rejected — it is salvaged into a raw dictionary and returned through an attribute still annotated as a
model — and an unparseable item in a list is now skipped so the rest of the list can parse, where the whole
response used to fail.Both behaviours are reasonable for a protocol library and neither is announced at a call site, which is the
problem: they silently retire validation this client was leaning on. Nothing here reads an agent-controlled
response field off its annotation any more. The pin is one exact release rather than a range or a minor
wildcard, because the lock file is not shipped and the published install command carries no constraint of
its own, so this is the only bound that reaches a user. A minor wildcard would not have helped:==0.12.*
and<0.13admit the same unreleased 0.12.z patches, and the lesson of this very bump is that a
compatible-looking release can retire validation without touching a signature. Only the release actually
run through the malformed-payload suite is claimed.
Fixed
-
An agent's launch path now resolves to its real on-disk filename case.
shutil.whichnever reads the
directory entry: it returns the caller's own spelling joined to the directory, plus — on Windows — each
PATHEXTentry appended verbatim. An uppercasePATHEXTtherefore nameskiro-cli.EXEfor a file whose
dirent iskiro-cli.exe. Windows opens either spelling, so the process starts and nothing looks wrong;
but a launcher shim that looks its ownargv[0]basename up in a case-sensitive table finds no entry,
prints one line, and exits before reading a byte of stdin. Every seat behind such a shim failed in under
0.15s, andconnect_onlyfailed identically, because the death is at spawn and never reachesinitialize.The normalization runs on every platform rather than under a Windows guard. The
PATHEXTmechanism is
Windows-only but the defect is not: macOS ships a case-insensitive filesystem by default, wherewhich
likewise returns the caller's spelling for a differently-spelled dirent andexecvepasses it through
unchanged. On a case-sensitive filesystem the exact-match branch returns the input untouched, so it is
self-neutralizing there. It deliberately does not usePath.resolve()/realpath, which would also
follow links and pin a version-managednodeshim to one concrete install directory, and it cannot use an
exists()probe, which is case-insensitive on exactly the platforms carrying the bug. Where two entries
differ only in case and neither matches exactly, the input is returned rather than guessing at a different
binary. The same normalization is applied to thenodefallback inside npm-shim resolution, which was a
secondwhichcall with the same exposure. -
An agent's stderr is captured and a bounded excerpt included in handshake failure details. It was previously
discarded, so a child that explained itself precisely and died surfaced only as "Connection lost" — a
description of the socket, not of the cause — and diagnosing one meant reproducing it by hand outside
Rutherford. The pipe is owned by Rutherford and drained continuously from spawn to EOF, which is what makes
it safe: inheriting the host's stderr is what once let an undrained pipe wedge the MCP host, and discarding
it was the previous fix. Retention is head-bounded, because the failure this exists to explain prints its
one useful line first, and draining continues past the cap so the child can never block on a write. The
text is agent-authored, so it is stripped of ANSI/OSC escape sequences and control characters — which can
retitle a terminal, forge a hyperlink, or write the clipboard — then masked for credential shapes, then
capped by line and byte count and fenced, so where Rutherford's own words stop is unambiguous. It is
attached only where a process actually existed;ACP_SPAWN_FAILEDmeans the spawn itself failed, so there
is no child and never a tail.The masking exists because the subprocess inherits a credential-bearing environment, so an agent that
prints a token on the way out would otherwise put it in a result the caller reads and a durable job keeps.
Stripping runs before masking, so a sequence spliced into a token cannot evade it. It matches known shapes
and is not a guarantee — an unrecognized credential format survives it — and it is deliberately
conservative, because an entropy heuristic would eat the hashes, paths, and model ids that make the
diagnostic worth having.docs/security.mdnow describes this path rather than implying it cannot exist.
Covered shapes include credentials embedded in a URL (https://user:pass@host) — in practice the likeliest
way one reaches stderr at all, since git, npm, pip and curl all echo the URL back on an auth failure, and
there only the password is dropped, because which host rejected the login is the diagnostic. Armored
private-key blocks are covered too, across PEM (including the encrypted traditional format, whose
Proc-TypeandDEK-Infoheaders a base64-only matcher misses), PGP, and RFC 4716 SSH2 armor. -
The server reported FastMCP's version as its own.
serverInfo.version, which every MCP client reads
atinitialize, was filled by FastMCP with its own version because the argument was omitted: a client
connecting to 3.2.0 was shown3.3.1— a string matching no release of this package, that moved whenever
FastMCP updated, and that read as newer than the release it was describing. It now reports the
distribution version. -
The entrypoint check could not detect a server that fails to boot.
--smokebuilds the app and
returns beforemcp.run, so the gate stage named for the entrypoint proved config loading and registry
construction and nothing about the transport. A newserver-bootstage starts the stdio server for real
and asserts what only a live exchange can: thatserverInfo.versionon the wire is this distribution's,
that every tool registers and a real call returns a non-error result, and that nothing but JSON-RPC
reaches stdout — a stray write there corrupts the protocol for every client. The child runs unbuffered,
because a piped stdout is block-buffered and a stray write would otherwise sit in it unseen, and both
streams are read on threads so a server that boots and then says nothing fails on a deadline instead of
hanging. -
A handshake that timed out reported an empty reason.
asyncio.TimeoutErrorstringifies to nothing, so
the detail read "ACP handshake with failed: " and stopped. It now names the fault type, keeping a
timeout distinguishable from a closed pipe. -
Codex
model+effortno longer fails asMODEL_UNAVAILABLEon Codex ACP 1.8. Codex 1.8
advertises bare model ids (e.g.gpt-5.6-terra), notbase[xhigh]. Rutherford was rewriting
effort=maxintogpt-5.6-terra[xhigh]before advertisement checks, then rejecting a model the
agent actually offered. The bracket id is used only when advertised; otherwise the advertised bare
model is selected andreasoning_effortis applied withcurrent_valueconfirmation. The two channels
have different ceilings: abase[tier]id cannot encodemax, but the config option is clamped to what
the agent advertises and current codex listsmaxthere, so the fallback applies a tier the bracket path
could not. A matching base id is never treated as proof the bracket effort applied: missing
reasoning_effortafter that fallback isACP_HANDSHAKE_FAILEDnaming the effort. MCP tool
descriptions now listmaxalongsidelow|medium|high|xhigh. -
A malformed
agentCapabilitiesfrom an agent no longer crashes a resume and orphans its process. Under
the new lenient deserialization, aninitializeresponse whose capability blob fails validation arrives as
a plain dictionary instead of raising. The resume gate readload_sessionstraight off it and raised
AttributeError— after the agent was spawned, outside every handshake guard, and from inside the context
manager's entry, which Python answers by skipping its exit. The subprocess was left running with nothing
holding a reference to it. The capability is now read through a helper that reports advertised, not
advertised, or unreadable, and an unreadable blob is a cleanRESUME_FAILEDthat says so rather than
claiming the agent does not support resume. The two are different facts and an operator debugging one
should not be handed the other.The teardown fix is deliberately wider than the read that exposed it. Every step in session open that runs
after the agent is spawned — creating or loading the session, reading what came back, and model and effort
selection — now sits inside one guard that closes on any exception, so a step added there later inherits
the teardown instead of having to remember it. Only the capability read was defective; the guard is
structural so the next one cannot be. -
A config option tagged with a foreign category is no longer driven as the model channel. The SDK now
generates a config option'scategoryas either a string or an object, an artifact of how the schema
describes it — every category the protocol actually defines is a string. An object-valued category
therefore parses where it used to be rejected, and comparing it against a category name quietly evaluated
false. It is now narrowed to a string once and reported as untagged otherwise, which is what the protocol
asks of clients: an unknown category must be handled gracefully, never required for correctness.Completing that discriminator exposed a second defect and fixed it. The category was already documented as
authoritative over the fallback that matches an option whose id is literallymodel, but that was only
honoured in one direction. An option the agent explicitly tagged as a mode, a model parameter, or a thought
level is now disqualified from the id fallback, so a mode selector that happens to be keyedmodelcan no
longer be driven as the model channel and have the mode it returns recorded as a confirmed model. Unknown
and vendor-prefixed categories still reach the fallback, because the protocol reserves those for custom use
and an agent may legitimately put one on its genuine model selector. -
Log records from libraries no longer bypass the non-blocking stderr writer. The ACP SDK defines no
logger of its own and reports handler failures through the root logger, which it did not do before 0.12.
Python installs a plain synchronous stderr handler on the root logger the first time anything logs through
it without one configured, and leaves it there for the life of the process — so the first such record would
wire every later traceback, from any library, to a synchronous write on the event loop thread. That is
exactly the stall the background writer exists to prevent for this project's own records. Logging setup now
owns a handler on the root logger, which both routes those records through the queue and pre-empts that
installation outright; silencing logs installs a null handler there for the same reason.Foreign root handlers are left alone and the root level is untouched, so raising this server's verbosity
does not drag every dependency's debug traffic onto the wire. Records that did not originate here are
wrapped as aforeign_logevent with the traceback escaped into a single field, because this stream is one
JSON object per line by contract and a raw traceback would make a log shipper treat the incident as
malformed input and discard it. -
A sandboxed write that fails on disk now returns a clean protocol error. Writing a file let an
OSError
escape unwrapped, where every other client callback already converts one into a protocol error. Under 0.12
that difference began to matter: the SDK re-raises a protocol error without logging but formats anything
else as a traceback, and anOSErrormessage carries the filename it failed on — the resolved absolute
sandbox path, not the relative one the agent asked for. The write now fails the way a read already did, and
the path stays out of the log. A failed write is still not journalled as a denial, which is reserved for the
policy refusing. -
An unreadable
RUTHERFORD_DEPTHis now fatal instead of being read as top level.current_depth()
turned a malformed or negative value into0, and a unit test asserted that as correct. Depth0is the
only depth permitted to requestdirect_workspace_mutation, so the failure mode of a garbled environment
was to grant the one privilege the depth check exists to withhold. A value that is present but unusable now
raisesINVALID_INPUTnaming the variable. Absent still means top level, because absent and "a genuine
top-level start" are the same observation and cannot be told apart.Upgrading, a process that sets
RUTHERFORD_DEPTHto a non-integer or negative value will now fail its
delegation instead of silently running as top level. Unset the variable for a genuine top-level run. -
The delegation depth cap now applies across process boundaries, which changes behaviour. Rutherford
writesRUTHERFORD_DEPTHinto every agent it spawns, but nothing read it back —current_depth()had no
callers — so depth restarted at zero in each process andmax_depthonly ever counted within one. A chain
of nested Rutherfords (an agent that is itself running one of these servers) could recurse without limit.
Every tool that spawns an agent now seeds its depth from the environment.Upgrading, a nesting chain deeper than
max_depth(default3) that previously ran will now be refused
withMAX_DEPTH_EXCEEDED. That is the cap doing what it always said it did; raisemax_depthif the depth
was intended. -
Subprocess deadlock from the inherited MCP stdio pipe —
npm install, the sandboxgitcalls,
and workspace fingerprinting now passstdin=subprocess.DEVNULL, so a helper child never inherits
the live MCP transport. This fixes observed Windows hangs where the child froze at 0% CPU and did not
respond tokill(), clearing only when the server exited. Contributed by
@Artemonim in #22. -
MCP host deadlock during ACP teardown — session
close/cancelare bounded and shielded, so
cancelling the waiter no longer abandons teardown. Descendants are snapshotted before the adapter is
killed, because they reparent away once it exits and are then invisible. Teardown runs snapshot, kill
the adapter, kill brokered terminals, reap descendants, close transport — closing first left inherited
stdio handles held by live descendants while the SDK waited on EOF. Contributed by
@Artemonim in #23. -
Teardown deadlines bound how long a stage is waited on, not whether it happens. An unbounded
pre-kill snapshot never returns, so the body never reaches its kill and thefinallynever runs —
stranding the very process teardown exists to collect. That applied to the session adapter and to
brokered terminals; a terminal is a child of the server rather than the adapter, so the session's reap
walks a different tree and would never have collected it.Where a stage has work that cannot simply be redone, the deadline now stops the waiting and leaves the
work running. Cancelling it instead looks equivalent but is not. Ato_threadstill queued on a busy
executor has not started, so cancelling it succeeds and the reap never runs at all, discarding a tree
that was already captured — and executor pressure is the exact condition these deadlines exist for.
Closing the transport is the same class for a different reason: the ACP SDK marks the connection closed
before awaiting its dispatcher stop, sender close, and task shutdown, so a cancel part-way through
strands the rest forever, since every later close returns immediately against the flag already set.
Only a genuinely repeatable wait is still cancelled — asession/cancelreply, whose payload is handed
to the sender's queue fully serialized, so giving up on it cannot truncate a write.The session and terminal paths share one implementation of this, including a single owner for work
that outran its caller. Previously each kept its own, and the session's stopped backing tasks past a
fixed count — so a busy teardown could drop exactly the work its deadline had promised to let finish.
A snapshot that lands after its deadline is reaped rather than dropped, and a timeout, a failure, an
undispatched reap, or a backlog of cleanups that are not completing is logged rather than passing for
an empty tree. -
Structured logs go through a non-blocking writer.
The writer's queue is bounded; when a wedged sink causes it to overflow, the dropped count is reported
as alog_records_droppedrecord rather than vanishing — the gap is visible in the same JSON stream,
anchored before the next record. -
A bracket inside a multi-line TOML string is no longer read as array structure. The config bracket
scanner carries multi-line string state across lines now, in both the strip and the insert paths, and
honors backslash-escaped delimiters. Without it a line beginning[inside such a string read as a table
header. -
An unreadable agent-registry cache is a cache miss rather than a traceback.
Security
-
The trusted-workspace allowlist can no longer fail open under an interleaved edit. The
read-modify-write on the global config is serialized by a lock file now, held across the read as well as
the write. Atrustracing anuntrustcould otherwise put back an entry the user had just revoked —
an allowlist that fails open, which is the one direction this gate must never fail. There is deliberately
no automatic stale-lock breaking: age proves the holder is slow, not that it is gone, and breaking on it
lets two waiters both believe they hold the lock. A leftover lock times out with a message naming the file
to delete, and release verifies the lock is still ours before unlinking it. -
The allowlist editor is unreachable from model-callable code by construction. The read-only breadth
check moved toconfig/workspace.py, so no tool has any reason to importconfig.trustat all, and an
AST check enforces that as a total ban rather than a name grep — which had missed both an alias and
from ..config import trust. A runtime namespace check backs it up against a dynamic import. -
Trusting a very broad path now says so. The gate is a prefix match, so trusting a directory trusts
everything beneath it; trusting a filesystem root, a home directory, or the parent of all homes warns.
It warns rather than refuses, because the CLI is an explicit human act and a checkout really can live at
/opt. The warning also rides thesetuptool's result, since that path is model-callable and has no
terminal to print to. -
Every GitHub Action is pinned to an immutable commit SHA. The workflows referenced floating tags
(actions/checkout@v7), which a tag move can repoint at new code without any change here. They now
name a SHA with the human-readable ref beside it. This matters most on the release workflow, which
holdsid-token: writefor PyPI trusted publishing andcontents: writeto create the release: a
compromised action there would run inside a job able to publish. -
The stdio transport is pinned rather than inherited. FastMCP resolves an omitted transport through a
pydantic-settings field with anFASTMCP_env prefix and.envsupport, soFASTMCP_TRANSPORT=httpin
the environment would have started a Starlette HTTP server with no code change. Rutherford is an ACP
orchestrator spoken to over stdio by an MCP client; that HTTP stack arrives only as a transitive
dependency and is neither used nor tested here. Naming the transport makes stdio an invariant instead of
a default, which is what keeps the stack unreachable.