Skip to content

v3.2.0

Latest

Choose a tag to compare

@github-actions github-actions released this 05 Sep 18:26
· 1 commit to main since this release

Added

  • direct_workspace_mutation on delegate, behind an operator opt-in — a write / yolo agent can
    edit working_dir itself, 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-HEAD starting 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_mutation in config, and the directory has to be on the trusted_workspaces
    allowlist: a per-call trust_workspace=true deliberately does NOT qualify, because a caller that can set
    it could otherwise authorise its own unsandboxed writes and the allowlist would decide nothing.
    working_dir must be named explicitly rather than inherited from the server's own directory, the call
    must not be nested inside another delegation, and propose cannot use it at all.

    Each admitted run logs before launch and again on completion, and the result carries
    direct_mutation=true so 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.md says 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 in docs/security.md rather 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, per docs/security.md, gains
    nothing here it did not already have, since a write / yolo agent was never OS-jailed.

Changed

  • FastMCP is bounded at the major, and the dependency set users actually resolve is now tested. The
    pin was fastmcp>=3.3 with no ceiling. CI installs with uv sync --locked, but the lock is not shipped
    and the published install instructions carry no constraint, so a fresh uvx 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 uv ecosystem. 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 from Connection.__init__.
    None of that reaches this package, which drives the SDK through spawn_agent_process rather than
    assembling a connection by hand, so no removed symbol is named here.

    What made the bump acceptable is that _deserialize.py is 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.close also got safer:
    it rejects pending requests first and moves the task shutdown into a finally, 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 — four id fields became required, the content-block
    and config-option unions gained discriminators, and several open str fields narrowed to Literal
    unions, including the config-option category that the model and effort channels read.

    The Dependabot ecosystem changes from pip to uv because the pip ecosystem updates the manifest
    without regenerating uv.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.13 admit 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.which never reads the
    directory entry: it returns the caller's own spelling joined to the directory, plus — on Windows — each
    PATHEXT entry appended verbatim. An uppercase PATHEXT therefore names kiro-cli.EXE for a file whose
    dirent is kiro-cli.exe. Windows opens either spelling, so the process starts and nothing looks wrong;
    but a launcher shim that looks its own argv[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, and connect_only failed identically, because the death is at spawn and never reaches initialize.

    The normalization runs on every platform rather than under a Windows guard. The PATHEXT mechanism is
    Windows-only but the defect is not: macOS ships a case-insensitive filesystem by default, where which
    likewise returns the caller's spelling for a differently-spelled dirent and execve passes 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 use Path.resolve() / realpath, which would also
    follow links and pin a version-managed node shim 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 the node fallback inside npm-shim resolution, which was a
    second which call 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_FAILED means 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.md now 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-Type and DEK-Info headers 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
    at initialize, was filled by FastMCP with its own version because the argument was omitted: a client
    connecting to 3.2.0 was shown 3.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. --smoke builds the app and
    returns before mcp.run, so the gate stage named for the entrypoint proved config loading and registry
    construction and nothing about the transport. A new server-boot stage starts the stdio server for real
    and asserts what only a live exchange can: that serverInfo.version on 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.TimeoutError stringifies 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 + effort no longer fails as MODEL_UNAVAILABLE on Codex ACP 1.8. Codex 1.8
    advertises bare model ids (e.g. gpt-5.6-terra), not base[xhigh]. Rutherford was rewriting
    effort=max into gpt-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 and reasoning_effort is applied with current_value confirmation. The two channels
    have different ceilings: a base[tier] id cannot encode max, but the config option is clamped to what
    the agent advertises and current codex lists max there, 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_effort after that fallback is ACP_HANDSHAKE_FAILED naming the effort. MCP tool
    descriptions now list max alongside low|medium|high|xhigh.

  • A malformed agentCapabilities from an agent no longer crashes a resume and orphans its process. Under
    the new lenient deserialization, an initialize response whose capability blob fails validation arrives as
    a plain dictionary instead of raising. The resume gate read load_session straight 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 clean RESUME_FAILED that 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's category as 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 literally model, 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 keyed model can 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 a foreign_log event 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 an OSError message 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_DEPTH is now fatal instead of being read as top level. current_depth()
    turned a malformed or negative value into 0, and a unit test asserted that as correct. Depth 0 is the
    only depth permitted to request direct_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
    raises INVALID_INPUT naming 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_DEPTH to 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
    writes RUTHERFORD_DEPTH into every agent it spawns, but nothing read it back — current_depth() had no
    callers — so depth restarted at zero in each process and max_depth only 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 (default 3) that previously ran will now be refused
    with MAX_DEPTH_EXCEEDED. That is the cap doing what it always said it did; raise max_depth if the depth
    was intended.

  • Subprocess deadlock from the inherited MCP stdio pipenpm install, the sandbox git calls,
    and workspace fingerprinting now pass stdin=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 to kill(), clearing only when the server exited. Contributed by
    @Artemonim in #22.

  • MCP host deadlock during ACP teardown — session close / cancel are 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 the finally never 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. A to_thread still 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 — a session/cancel reply, 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 a log_records_dropped record 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. A trust racing an untrust could 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 to config/workspace.py, so no tool has any reason to import config.trust at 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 the setup tool'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
    holds id-token: write for PyPI trusted publishing and contents: write to 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 an FASTMCP_ env prefix and .env support, so FASTMCP_TRANSPORT=http in
    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.