Repository navigation
io-cli 0.40.0 - #111
Merged
Merged
io-cli 0.40.0#111
Conversation
The pin carries two things this release needs and neither is io-cli's to fix: `sandbox.allow_network = true` now reaches the macOS backend on a proxied run, which is what O1 verifies end to end, and `git_log` on a repository with no commits answers in its own words rather than with the error git also returns for "not a git repository". Every `io-harness-0.82.0/<path>:<line>` citation under src/ and tests/ was verified against the 0.83.0 tree rather than having its version string rewritten. Four line numbers moved: the three `[run]` defaults in contract.rs (755,773,789 -> 780,798,814) and `withheld_sentence` in run/prompts.rs (1381 -> 1418). Nothing cited is gone and no cited item changed meaning.
…er door
Thirteen keys reached the fall-through that quotes any unrecognised
`app.io-cli.*` word, so `io config set app.io-cli.reference_catalogue false`
wrote the string "false" and io-harness — which reads [app.io-cli] as one
opaque value — failed the whole section, reverting theme, keybindings,
containment, gates and routing to defaults behind a single warning line.
0.38.1 fixed this for one key and the fix did not generalise, because
nothing in the suite walked from the settings struct to the catalogue.
The gate is that walk, and it was built before the fix so it produced the
list rather than confirming one. It found a fourteenth key the analysis had
missed: `BrowserConfig` also carries `binary`. That one is deliberately not
offered — it names a program to execute, io-harness refuses a whole
top-level [browser] section in a workspace file for exactly that reason,
and it cannot apply that rule to a section it reads opaquely.
`max_total_duration` needed a kind of its own. It deserializes as a
`Duration`, so what the file must carry is `{ secs = N, nanos = 0 }` and no
scalar an operator types is a value for it — a bare 60 fails exactly as the
quoted "60" this release removes does. `Kind::Duration` takes the seconds
and writes the table. Its origin is read through `.secs`, because
`Config::origin` walks to a leaf and a duration is not one, so a value just
written was reading back as unset.
`browser.args` needed no new arm anywhere: it is the same act
`gates.command` has been since 0.24.0.
`configure::EXCLUDED` is the other half of the new gate — the leaves this
surface does not offer, each with the sentence saying why, so being left out
is a decision rather than a gap.
Through 0.39.0 `y`, `a` and `n` each resolved an approval on their own, so an operator who began typing their next instruction while one was up had already answered it with whatever letter came first. The 2026-09-05 field test caught it: the `n` in "end" denied a command, and the composer was left holding `lways enE.`, which was then sent as a prompt. Both ways in are still two ways in — a letter for the reader who knows the key, arrows for the one who does not — and both now end at the same `Enter`. It costs one keystroke; it buys that no single character an operator types can decide a write. Buffering and replaying the typed sentence is the fuller answer and is deliberately not here: it makes this path conditional on buffer state, and stopping the unintended decision is the part that could not wait. A boundary product whose whole claim is that a person decides cannot have a door where a letter inside a sentence decides for them.
`gated_bound` tested "the operator set no step cap" by asking whether io-cli's own floor of a thousand had survived `Config::apply_to`. That call sets the field unconditionally when the key is present, so `[run] max_steps = 1000` — the documented default, written down — was indistinguishable from having written nothing, and a gated headless run took forty steps against a file asking for a thousand. The one number an operator is most likely to write was the one value that did nothing. The comment here said io-harness's `run` section is private so there was no way to ask whether the key had been written. The section is private and the question is answerable: `Config::origin` names the files that set a key, its own doctest is `run.max_steps`, and `/config` has told a file value from a default through it since 0.28.0. The row that pinned the collision as a chosen cost is rewritten rather than deleted, and it moved: `Config::from_toml` has no file and therefore no origins, so `tests/gates.rs` cannot tell the two cases apart and says so rather than pretending. F5 lives in `tests/contract.rs`, over a real discovered `io.toml`, with the companion proving the floor still caps a gated run that wrote nothing. The forty-step cap and the four other sentences this release corrects are documented and gated: the commit identity read from `Identity::default()` rather than typed, the cap named in the headless guide, the `--json` reasoning-order caveat, and the two stale widening claims. `io.local.toml` has been widening-checked since io-harness 0.74.0 and three pages still offered it as the place to put a widening value — including the permissions skill, which is what advised the field tester into the refusal they hit — and the refused list is thirteen pairs across twelve keys, not five.
…only what will be taken Two `/config` doors disagreed. The value arm has checked `widens_workspace` since 0.28.0 and refuses before it writes; the scope arm offered all three files and let the round trip refuse two of them afterwards — so an operator choosing `sandbox.mode = "full-access"` was shown `io.toml` and `io.local.toml` as places to put it, picked one, and met a refusal the surface already knew was coming. And the refusal is of the whole file, not of the key, which is the worst moment to learn the rule. `configure::writable_scopes` is the decision, in the library, because nothing under `tests/` links `src/main.rs`. The round trip stays as the backstop and is not redundant: it is what restores "absent" rather than "empty" for every refusal the predicate does not model — a refused `[browser]` section is not a widening value, and that arm is what covers it. ACP declares `image` and `embeddedContext`. A `resource` block carries the text of the file an editor has open and was dropped silently, so the model was asked about a file it had never been given; it is folded into the prompt with its uri now, and a `resource_link` is folded as the mention it is — never read here, which would be a file access outside the session's own policy made by the adapter instead of by the agent. An `image` block goes through `Media::attach`, the same door `/attach` uses, so it gets io-harness's media-type table, its pixel-bomb guard, its byte bound and its conversion of the five formats a provider will not take. Constructing a `Media` out of the frame's two fields would have been shorter and skipped every one of those, so the base64 decode is fifteen lines of table lookup rather than an eleventh dependency. A block that cannot be taken is reported to the client as a message: an editor that attached one unreadable screenshot must not lose its question, and must not get an answer written without the picture and no word about it. `audio` stays false. io-harness's `Media` has an image media type and nothing else, so there is nothing one layer down to carry the bytes and declaring it would be a promise this product cannot keep.
…as skipped `Cargo.toml` said the no-new-crate claim for `otel`, `mcp-server` and `codeact` was "verified against `cargo tree --depth 1` … see `tests/dependencies.rs`". It was not: that file parses `Cargo.toml` and `Cargo.lock` and never runs cargo, and `--depth 1` prints io-cli's own direct dependencies, which is exactly where a transitive crate would not appear. The claim is true and checkable at its source — a feature that turns nothing on has an empty list, and one that enables a feature of a crate already present names no `dep:`. So the test reads the `[features]` table of the io-harness version `Cargo.lock` pins and asserts that, with a control asserting the table parsed at all: a path miss is how a manifest gate goes vacuous rather than red. The sentence in `Cargo.toml` was amended to say what is actually asserted. `docs/RELEASE_PROCESS.md`'s tap step gains the reason it is not an errand. 0.39.0 shipped on 2026-09-06 with `Formula/io.rb` and `bucket/io.json` still naming 0.38.2, so for anyone installing through brew or scoop the release notes described a version they could not get. `docs/CONTRACT.md` gains the forty-step gated cap, which decides whether an unattended run finishes and appeared in no shipped document at all.
…re settable Driving the argv door found the thirteenth key. The two list keys are a command line and a browser's arguments, and the value of either routinely begins with a dash — so `io config set app.io-cli.gates.command cargo test --all-features` was refused by io's own flag parser, naming `--all-features` as a flag it does not take. That invocation is the worked example `docs/config.example.toml` has shipped since 0.24.0 and it had never run; `--disable-gpu` is the same word one release later, which is how this release met it. Nothing below the door would have found it. `tests/configure.rs` drives `configure::source_for`, one layer past the parse that refuses, so the unit gate for `browser.args` was green while the command an operator types could not run. Fifth consecutive release in which driving the real command line caught something. The mechanism is the one this parser already has and already recommends: its refusal for a single-dash token says outright that a flag meant for the program belongs after `--`, and `scan` stops dead at the first `--` so everything past it is copied through as text. `config set` accepts that section for a list key and keeps refusing it for every other key, where the words are one value and a separator before them is a mistake worth naming. The two documented invocations are corrected to carry the separator.
…efusal as a success
Fourteen findings against a green suite. Four were behaviour.
**`configure::EXCLUDED` enforced nothing.** It was added as the other half of
the settings-struct walk, and both `/config` doors reached the fall-through
regardless: `kind_of` answers `None` for `app.io-cli.browser.binary`, so it
fell to the arm that quotes any unrecognised `app.io-cli.*` word and produced
a valid string for an `Option<String>` field. `io config set
app.io-cli.browser.binary /usr/bin/x --scope project` succeeded and took
effect — the exact act the entry's own reason says must not be offered, in
the one section io-harness reads opaquely and never widening-checks. The
reason strings had no reader at all; `source_for` consults them now, so they
are what the operator is told and what keeps them true.
**`browser.args` was offered at project scope on the argument used to
exclude `binary`.** It is the argument vector of that same program, which is
where a `--proxy-server=https://user:pass@host` or a `--load-extension` goes
— io-harness redacts it from its own `Debug` for exactly that shape.
Committing the arguments is committing the binary one word further along.
`configure::USER_SCOPE_ONLY` holds it to the user scope at both doors, with
the reason attached.
**ACP declared `image: true` and never asked the provider.** io-harness
refuses the whole turn at request time for a text-only model, and this door
reports that on stderr — so the client saw a turn that ended having said
nothing, which is the operator's question thrown away. `prompt_images` takes
`accepts_images` now, which is the guard `/attach` has always had.
**An image-only prompt lost its refusals.** The goal was computed first, so
`InvalidParams` returned on the `?` before any refusal sentence was sent —
for exactly the prompt `image: true` invites. Images are decoded and spoken
about first; the refusal for a prompt with no words in it says what a prompt
must carry. An embedded blob is named too, rather than skipped in silence.
**A live gate read the mask working as the mask failing.** `live_f6` matched
`Step { tool_call }` on the `write_file:` prefix — and a REFUSED call commits
a step with that same prefix and `decision: "write_file refused: withheld
from this turn"`. The 0.40.0 live run is the first in which a model actually
attempted the withheld call, so the arm had never fired; the decision is what
separates the two outcomes its own comment describes.
Six more shipped pages sent an operator to `io.local.toml` for a section
io-harness refuses from any file inside the workspace — `[[hook]]`,
`[[plugin]]`, `${cmd:}` — including two agent-read skills. The N2 sweep that
should have caught them greps five literal phrases, so it asserted its
fixture rather than its property; it is paragraph-scoped over the refused
sections now. Its first draft was vacuous too, excusing any paragraph
containing "may not" — which is how the stale hooks page opens — and that was
found by restoring the page from git and watching the gate stay green.
The rest: an ordering comment in `undo_whole_turn` that invented a reason,
two "exactly one list key" claims false in the release that adds the second,
an F1 completeness loop that checked eleven of thirteen while claiming all,
a `spell_value` arm reasoning about a call that cannot happen, a sabotage
control named after a test that does not exist, an example paragraph that
reverted to the corrected-away wording, a refusal ending in MCP wording on a
`config set`, and an image refusal with a hole where the media type goes.
io-cli 0.40.0
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Every key io documents can be set from either door, and nothing decides on your behalf.
What changed
Thirteen
[app.io-cli]keys are settable from the shell at last.io config setwrote a string wherever a number or a flag belonged, for every keykind_ofdid not name — and the cost is not the key. io-harness reads[app.io-cli]as one opaque value, so one bad value fails the whole section and the session silently reverts theme, keybindings, containment, gates and routing to defaults behind a single warning line.reference_catalogueis the sharpest of them: 0.39.0 shipped it as the way to turn its own new network call off, and left it unsettable.0.38.1 fixed this class for one key and the fix did not generalise, because nothing in the suite walked from the settings struct to the catalogue — a list cannot notice what is missing from it. That walk exists now. It was built before the fix so that it would produce the list rather than confirm one, and it found the fourteenth key the release contract's own risk predicted:
BrowserConfig::binary, hidden until then becauseBrowserConfig::default()leaves itNoneand aNoneis dropped byskip_serializing_if.An approval waits for
Enter. Through 0.39.0y,aandneach resolved one on their own, so an operator typing their next instruction had already answered with whatever letter came first. The 2026-09-05 field test caught it as thenin "end" denying a command and leavinglways enE.in the composer, which was then sent as a prompt.A
[run] max_steps = 1000an operator wrote now beats the forty-step gated cap. It was the one number they were most likely to write and the one value that did nothing.Plus:
/undoforgets the thought as well as the files; the/configscope picker offers only scopes a write will be taken in; ACP declaresimageandembeddedContext; and five sentences that were false are corrected, each with a gate.The pin moves to io-harness 0.83.0, which is what makes
sandbox.allow_network = trueactually widen the sandbox — the field test's critical finding.Behaviour changes
Both are in the CHANGELOG under an
### Upgradingheading: an approval now needsEnter, and a gated run that wrotemax_steps = 1000now runs to a thousand steps where it took forty.Verification
cargo test --all-targets --no-fail-fast— 2,053 passed, 0 failed, 42 ignored, across 109 binariescargo test --doc— 1 passedcargo clippy --all-targets -- -D warnings— exit 0cargo fmt --all -- --check— clean, run last on the shipped treeStepby itswrite_file:prefix, and a refused call commits a step with that same prefix. Corrected to read the decision, re-run green.sandbox.allow_network = truea contained run bound a loopback port (BOUND 55219); with the key back at default the identical run was refused withPermissionError: [Errno 1] Operation not permitted.io config set app.io-cli.gates.command cargo test --all-featureswas refused by io's own flag parser: the worked exampledocs/config.example.tomlhas shipped since 0.24.0 had never run. Nothing below the door could have found it.The release record is sealed and pinned at
sha256:4b5e36bd95f031985d57da20e17f4521466a4b7d9eff40b0d25978d75d6cec7a;ultraship validateis ok over 132 checks.