Skip to content

io-cli 0.40.0 - #111

Merged
aakashpawar1999 merged 10 commits into
mainfrom
develop
Sep 7, 2026
Merged

aakashpawar1999 merged 10 commits into
mainfrom
develop

Conversation

@aakashpawar1999

Copy link
Copy Markdown
Contributor

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 set wrote a string wherever a number or a flag belonged, for every key kind_of did 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_catalogue is 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 because BrowserConfig::default() leaves it None and a None is dropped by skip_serializing_if.

An approval waits for Enter. Through 0.39.0 y, a and n each 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 the n in "end" denying a command and leaving lways enE. in the composer, which was then sent as a prompt.

A [run] max_steps = 1000 an 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: /undo forgets the thought as well as the files; the /config scope picker offers only scopes a write will be taken in; ACP declares image and embeddedContext; 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 = true actually widen the sandbox — the field test's critical finding.

Behaviour changes

Both are in the CHANGELOG under an ### Upgrading heading: an approval now needs Enter, and a gated run that wrote max_steps = 1000 now 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 binaries
  • cargo test --doc — 1 passed
  • cargo clippy --all-targets -- -D warnings — exit 0
  • cargo fmt --all -- --check — clean, run last on the shipped tree
  • The live suite (real provider) — 42 tests. One failed, and it was a defective gate rather than a defective product: it matched a Step by its write_file: prefix, and a refused call commits a step with that same prefix. Corrected to read the decision, re-run green.
  • O1 end to end, from a real command line: with sandbox.allow_network = true a contained run bound a loopback port (BOUND 55219); with the key back at default the identical run was refused with PermissionError: [Errno 1] Operation not permitted.
  • O3, the argv door — and it is what found the thirteenth key. io config set app.io-cli.gates.command cargo test --all-features was refused by io's own flag parser: the worked example docs/config.example.toml has shipped since 0.24.0 had never run. Nothing below the door could have found it.
  • Eight gates sabotaged one at a time, each restored and re-run green.
  • An adversarial diff review found 14 real defects behind a fully green suite — four of them behaviour, including an exclusion list that enforced nothing. All addressed.

The release record is sealed and pinned at sha256:4b5e36bd95f031985d57da20e17f4521466a4b7d9eff40b0d25978d75d6cec7a; ultraship validate is ok over 132 checks.

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.
@aakashpawar1999
aakashpawar1999 merged commit b82bded into main Sep 7, 2026
14 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant