Skip to content

io-cli 0.35.0 - #92

Merged
aakashpawar1999 merged 22 commits into
mainfrom
develop
Sep 2, 2026
Merged

aakashpawar1999 merged 22 commits into
mainfrom
develop

Conversation

@aakashpawar1999

Copy link
Copy Markdown
Contributor

The release that renders a boundary somebody measured — and the io-harness 0.74.0 security pin that makes it possible.

This interface has drawn the sandbox backend that answered rather than the mode that was asked for since 0.2.0, deliberately, because those are two different facts. Until io-harness 0.74.0 that rendered fact could still be false: on macOS a crafted workdir re-granted write and network on / under a reported MacosSandboxExec, and on Windows shell and git spawned entirely unwrapped under a reported WindowsAppContainer. 0.74.0 makes each backend try to write outside its roots and dial outside its egress at run start and records what happened. The status line, the footer and /status now render that outcome.

Taking that pin is most of this release.

What an operator has to act on

A file inside a workspace may declare far less. [[provider]], [[mcp]], [[lsp]] are refused from any project-scoped file; [[hook]] is now refused in io.local.toml too; a workspace-resident plugin may not contribute [[bin]] or [[hook]]; twelve keys may no longer be widened where five could not before; run.skills/run.templates may not be absolute. A refusal takes the whole file, not the key.

Local providers stop working until you lift the floor. Loopback, link-local, CGNAT, ULA and RFC 1918 endpoints — and localhost, *.localhost, *.local — are refused before the first step whatever the policy says. Ollama, LM Studio and llama.cpp users need IO_HARNESS_ALLOW_LOCAL_ADDRESSES=1. io names it in the refusal and sets it nowhere.

A boundary refusal now exits 2, not 1. A script branching on 1 for that case will see 2, which is what docs/CONTRACT.md always said.

Foreign bundles: installing again is how you update. The adapter ships a copy of what it contributes instead of pointing into the clone, because io-harness refuses the latter everywhere — every *.md under such a directory reaches the model's system prompt.

What the gates found

101 test failures on the day the pin moved, 0 at the end — every one read and classified rather than made green.

The planning audit falsified five contract claims before any code, twenty-ninth consecutive release. The given-image feature was impossible as written: EventKind has fifty-two variants in both 0.73.0 and 0.74.0, so there is no event to consume. Config refusals carry no structural attribution. The probe result is not on the event. The /cost cache split had already shipped. run.templates was applied on one door.

Three production defects with no failing test. configure::widens_project held five widening pairs against the harness's thirteen, so /config was offering eight values that make the destination file stop parsing — found by reading the dependency's diff, because the gate iterated io-cli's own list and a list cannot notice what is missing from it. Its refusal sent the operator to --scope local, a file 0.74.0 also refuses, pinned green by a test asserting it should. And a re-install appended a duplicate [[plugin]] on every refresh.

Two gates that were green while asserting nothing. refuse_widening runs before deserialization, so five hooks.rs arms never reached Hook::check. And an import test asserted a refusal count as evidence about ${env:} substitution while both entries were refused for the section.

The width gate caught the headline feature breaking the footer on its first run — the probe word took the posture and the containment off the row together at eighty columns, because that group is fit all-or-nothing.

The live suite found two defects nothing offline could, because tests/live.rs never runs under cargo test: a project-scope [[provider]] fixture, and the provider fall-through test whose premise died when the floor started refusing a loopback primary with a non-retryable Error::Refused.

Evidence

  • cargo test --all-targets — 103 binaries, 1,863 passed, 0 failed, 37 ignored (0.34.1: 102 / 1,831 / 37)
  • cargo clippy --all-targets -- -D warnings — 0 · cargo fmt --all -- --check — clean · cargo test --doc — passes
  • Live — 34/37, then 36/37 after the fixes it prompted; the remaining failure is a provider Timeout that passes alone in 1.44s. Both numbers recorded.
  • Sabotage — four arms, each failing its own gate alone. One found a defect in the gate rather than the code.
  • ultraship validate — exit 0 over 109 checks

Known limitations

Carried in full in the release record. The one worth naming here: declining a re-install still applies the refresh, because the adapter is regenerated before consent is asked. It is strictly better than what shipped before it — declining used to delete the working adapter — but it is not right, and the fix restructures the consent flow. 0.36.0.

dependabot Bot and others added 22 commits September 1, 2026 23:50
Bumps [softprops/action-gh-release](https://github.com/softprops/action-gh-release) from 3.0.2 to 3.0.3.
- [Release notes](https://github.com/softprops/action-gh-release/releases)
- [Changelog](https://github.com/softprops/action-gh-release/blob/master/CHANGELOG.md)
- [Commits](softprops/action-gh-release@3d0d988...efb3536)

---
updated-dependencies:
- dependency-name: softprops/action-gh-release
  dependency-version: 3.0.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <[email protected]>
0.74.0 is a security release. It builds with no compile errors, which is
what makes it dangerous: every break in it is behavioural. 101 of 1831
tests fail, almost all of them fixtures declaring providers, MCP servers,
hooks or absolute paths in a scope the harness now refuses.

The migration lands across the rest of this release.
io-harness 0.74.0 refuses [[provider]], [[mcp]], [[lsp]] and — in
io.local.toml as well as io.toml — [[hook]] from any file that lives in a
workspace. Scope::User is the only exemption, so every fixture in this
suite that declares one of those sections has to be user-scoped.

The trap the old fixtures fell into is that pointing IO_CONFIG at a file
is not enough. Config::discover pushes three candidates, and a file that
is both the IO_CONFIG target and the discovery root's own io.toml is read
twice — once as Scope::User and once as Scope::Project — and the project
read is the one that refuses it. The file has to live outside the root.

Config::from_toml cannot be used for any of it: it hard-codes
Scope::Project and calls refuse_widening unconditionally, so no argument
makes it produce a user-scoped configuration.

env_lock is now one lock per test binary. Fourteen files each declared a
Mutex of their own, and two different mutexes in one binary exclude
nothing from each other.

tests/scope.rs is the helper's own gate. The helper is load-bearing for
eighteen binaries, and a silent regression in it would not fail honestly
— it would fail a hundred tests elsewhere for a reason none of them
names. Running it found two defects in it: a reentrant lock that
deadlocked rather than failing, and a ${env:} credential the fixture set
and removed while sibling tests were mid-discover.

Task: US-IO-CLI-0.35.0-T01
io-harness 0.74.0 refuses [[provider]], [[mcp]], [[lsp]] and the ten
widening values from any file that lives in a workspace. Sixty-nine
fixtures across these nine binaries declared one of those in a project
file and went red on the pin.

All of them now build their configuration with support::user_scope.
Config::from_toml is not usable for any of it — it hard-codes
Scope::Project — so those call sites were converted rather than
adjusted.

Four local env_lock() declarations now delegate to support::env_lock.
Two different mutexes in one binary exclude nothing from each other.

Three assertions changed meaning, each an inversion rather than a
retreat:

  - wizard's the_rendered_file_is_the_harness_schema_and_nothing_of_our_own
    asserted that a narrowing posture parses at project scope. Every
    file settings::render writes carries [[provider]], refused at
    project scope whatever the posture, so the assertion now names
    which key the refusal cites — "it is refused" is true of both
    postures and would prove nothing.

  - configure's f4_the_same_value_is_accepted_in_the_local_scope is now
    f4_the_same_value_is_refused_in_the_local_scope_too. 0.74.0 holds
    Scope::Local to refuse_widening. The needle is the clause
    untrusted() uses to tell local from project, so it cannot pass
    against a write that had wrongly gone to io.toml.

  - wizard's f9_the_posture_step_resolves_the_row_the_query_left_visible
    lost one redundant arm whose premise was the old parse refusal.

contract.rs needed a local fixture rather than the shared one: a
contract reads [instructions] off disk relative to the discovery root,
so the workspace still has to hold GUIDE.md while the configuration
sits outside it. Its EVERY_SECTION was refused four ways at once —
[[mcp]], [[lsp]], [web], and an absolute run.skills.

Task: US-IO-CLI-0.35.0-T02
The other half of the 0.74.0 migration. marketplace.rs and import.rs are
deliberately untouched: which scope io-cli writes a marketplace install
and an MCP import into is a product decision those tasks own, and moving
the fixtures first would bake an answer into a test before the product
had one.

A whole test family was one edit away from passing while asserting
nothing. refuse_widening runs before deserialization, so at project
scope all five hooks.rs arms came back "may not declare hooks" — green,
without ever reaching Hook::check, the function they exist to exercise.
They now go through try_user_scope_locked so the arms reach their own
subject.

hooks.rs f8 was inverted rather than deleted. 0.74.0 holds Scope::Local
to refuse_widening, so "a local hook is the operator's own" stopped
being true. It asserts the refusal, names the io.local.toml clause, and
keeps a positive control at user scope beside it, so the pair still
proves the rule is about scope and not about hooks. The expected
sentence was read out of the harness source rather than typed from
memory.

plugins.rs gained a discovered(root) helper holding the env lock across
the read, and all seventeen bare Config::discover sites route through
it. Without it f1, f2, f3 and n2 would have failed intermittently on CI
only, on a bundle they never declared, because IO_CONFIG is
process-global and one test in the binary now sets it.

bundle_path.rs and plugins.rs moved their bundles outside the discovery
root and declare them from the user scope, which is where
home::adapters() actually puts one. A [[bin]] or [[hook]] from a
workspace-resident manifest is refused whatever declares it, so the old
io.local.toml shape dropped every bundle whole.

Suite: 103 binaries, 1827 passed, 9 failed, 37 ignored, from 101
failures before the wave. Every remaining failure is owned by a named
later task.

Task: US-IO-CLI-0.35.0-T03
try_user_scope_locked returns UserScope in a Result, and
Result::expect_err requires the Ok side to be printable. A fixture whose
failure case cannot be asserted on with the ordinary vocabulary is a
fixture tests work around, which is how the hooks.rs arms had been
asserting nothing.

Task: US-IO-CLI-0.35.0-T01
main holds the report precisely so no early return has to know to
deliver it, and then drained it inside the Err arm alone. The wizard's
decline is not an error: answering io setup with Esc returns
Ok(exec::OK). So an operator upgrading from a pre-0.15.0 install had
io.toml, runs.db and the rest moved into ~/.io-cli, pressed one key, and
was told nothing, with their old directory now empty. The same arm
swallowed the line that says a file could not be moved because another
io holds it.

Carried in from 0.34.1, which found it and documented it rather than
fixing it under a patch.

Fixed by moving the drain out of the match rather than adding a second
loop to the Ok arm. Every arm with somewhere better to speak has already
taken the report by mem::take, so the drain sees only what nobody said,
and the next early return cannot reopen the hole by not knowing about
it.

The gate asserts an ordering and a count, because contains cannot
express this defect — the pre-0.35.0 source contained the identical
loop, in the wrong place. Sabotage: moving the loop back into the Err
arm fails the new gate alone, with the pre-existing f6 staying green.

That sabotage also found a defect in the gate. Its first needle carried
eight spaces of indentation, so moving the loop into an arm — which
indents it to twelve — failed the find rather than the ordering
assertion: a gate coupled to whitespace, red on a reformat that changed
nothing and reporting the wrong reason for a real regression.

Nothing under tests/ links src/main.rs, so this proves where the code
sits and not that it runs.

Task: US-IO-CLI-0.35.0-T09
Every foreign-bundle install was dead on the 0.74.0 pin. adapt::generate
wrote absolute skills and templates paths into the generated manifest,
pointing back into the marketplace clone — which worked because
Plugin::skills_dir joins onto the plugin root and Path::join discards
its base on an absolute argument.

io-harness 0.74.0 refuses that in every scope, Scope::User included, and
the reason is the one this product would give: every *.md under such a
directory reaches the model's system prompt, so a manifest must
contribute a directory it ships rather than one it points at somewhere
else on the machine.

The adapter now copies skills/ and commands/ out of the clone into its
own root and writes one plain component for each.

directory() no longer canonicalizes. It used to resolve the path, which
silently follows a skills that is a symlink out of the clone — and a
refusal cannot be made about a path already resolved past the thing
being refused.

The copy is an iterative worklist rather than recursion, so the entry
bound is not also a stack-depth bound. It calls symlink_metadata on the
directory and on every entry, refuses a symlink and anything that is
neither a plain file nor a directory, and uses home::create so copied
directories get 0700. On any refusal the whole adapter directory is
removed rather than left half-written, and it is rebuilt on every run so
a file withdrawn upstream cannot survive in io's home.

Ordering is load-bearing: inside() filters the stranger's value and
Plugins::inspect accepts the manifest before anything is copied, so a
bundle naming "skills": "../../.." contributes nothing and copies
nothing. That is the 0.31.0 finding, which returned the operator's
$HOME.

MOST_BYTES is 16 MiB and MOST_ENTRIES 2000 per contributed directory,
argued against 48 real bundle directories on this machine — the largest
is 488,003 bytes over 77 entries. 2000 rather than 10000 deliberately,
so the gate asserting the bound can build a directory past it in under a
second: a bound no test can reach is a bound nothing checks.

agents/ is deliberately not copied. io-harness 0.74.0's Manifest has
exactly two directory keys; AgentDef carries its role text inline and
has no path field, so a copied agents/ would be bytes nothing reads.

Known consequence, closed by the next commit: adapt::generate has one
caller, reached from plugin add, so the adapter now holds a snapshot
taken at install time where a git pull used to be visible immediately.

Task: US-IO-CLI-0.35.0-T04
…ts list

widens_project held five hand-copied (key, value) pairs. io-harness
0.74.0's PROJECT_WIDENING holds thirteen over twelve keys: it added
policy.defaults.read and write, sandbox.mode = "workspace-write" beside
full-access, and each of the five sandbox.limits.* written as 0, which
means no cap.

So the copy was a strict subset of the rule it claimed to state, and
/config and io config set were offering eight values the destination
file refuses — and refuse_widening runs before deserialization, so each
refusal takes the whole file rather than the key. No test failed: the
gate iterated io-cli's own list, and a list cannot notice what is
missing from it. It was found by reading the dependency's diff.

widens_workspace now writes the one key into a one-line document and
hands it to Config::from_toml, which parses at Scope::Project and runs
refuse_widening before deserializing anything, then reports whether the
refusal carried the clause "widens the boundary". io-harness publishes
no reader for the table, so asking is the only form that does not go
stale again.

Values are written quoted whatever the key's real type, because
refuse_widening compares the written form of a string, a boolean or an
integer alike. A value that does not widen goes on to deserialization
and may fail there for a type error instead, which is why the answer is
the refusal's own clause and not merely is_err.

Both call sites gated on Scope::Project alone, so --scope local walked
past io-cli's own check and was caught only by configure::write's
rollback. And the refusal told the operator to write it with --scope
local, which 0.74.0 also refuses: io.local.toml is not committed, but it
sits in a workspace root the run's own agent can write to. io-cli was
shipping advice that fails, and tests/manage.rs asserted in both
directions that it should. The sentence now names --scope user, the only
destination left.

Two gates. The agreement loop covers all thirteen pairs, including the
eight io-cli never knew, which catches today's gap. And a source-text
gate requires the predicate to contain Config::from_toml and to name no
key or value literal, which catches the next one — a release that
answers a future divergence by lengthening a list has reintroduced the
failure mode, and a longer correct list still passes the loop.

Task: US-IO-CLI-0.35.0-T07
Carried in from 0.34.1, which found it while verifying that release's
fourth documentation claim and documented it rather than fixing it under
a patch.

A headless run whose provider endpoint lands in the ask tier is refused
by io-harness before the first step, and exec::turn stringified that
typed Error::Refused before an exit code was chosen — so it exited 1,
which docs/CONTRACT.md publishes as FAILED, where 2 is the code that
exists to mean a boundary said no. It is the mirror of the collision
0.34.1 removed and the same class: the one surface this product offers
to be scripted against, telling a script the wrong thing.

Ending { code, message } carries the status across the seam. A two-field
struct rather than a two-variant enum, because the code is the only
thing the seam decides, and Display over the error's own words keeps
stderr byte-identical — only the number beside it moves. exec::main,
resume_main, provider::build and main.rs all keep their signatures.

The arm is one variant wide, read out of io-harness's own doc comments
rather than assumed. Error::Refused is "typed separately from
Error::Config so a refusal is distinguishable from a malfunction";
Error::Sandbox is "the sandbox failed to start ... so a calling agent
can tell 'the sandbox never ran the code' apart from 'the code ran and
failed'". Neither Sandbox nor Config is a boundary refusal. The wildcard
gives anything a later harness adds FAILED, which is the conservative
direction.

Both doors are fixed and the second is not hypothetical: all four
crate::resume drivers funnel into resume_with_observed, which calls
authorize_provider, so the same refusal reaches io resume.

The gates are counted, never contains: DOORS has two entries and its
length is asserted, so a door added later has to come here. One arm per
variant for Sandbox and Config, because the tempting fix is a wildcard
that swallows both. And every variant the locked io-harness declares is
driven through each door, with the refusal set asserted to be exactly
["Refused"] — held against harness_error_variants(), read from the
locked source, so a harness adding a second refusal variant fails a test
that names it instead of being silently reported as a failure. That is
the one direction the wildcard does not cover, and 0.74.0 added two new
Refused shapes in a single release.

docs/CONTRACT.md and docs/guide/headless.md documented this as a known
limitation, and tests/docs.rs pinned both paragraphs. All three were
deleted rather than adjusted; the count gate owns the fact now.

The end-to-end arm is live-suite-only. A policy can only refuse what a
run does and a run must reach a provider first, so no offline test can
drive a refusal through the real argv door. Asserted at the seam against
a constructed error, with the limitation written into the DOORS doc
comment rather than disguised.

Task: US-IO-CLI-0.35.0-T08
The third call site of the widening guard, in the interactive picker.
It gated on Scope::Project alone like the other two, so choosing a
widening value for a key decided by io.local.toml was written and then
rolled back by configure::write instead of being refused before the
write. Its sentence named "the project file" for the same reason.

Also drops a .err().expect() left in tests/hooks.rs. Its comment said
UserScope is not Debug, which was true when it was written and stale by
the time it landed — the derive went in for the other half of the same
wave.

Task: US-IO-CLI-0.35.0-T07
/import is how an operator arrives from Claude or Codex with their work.
On the 0.74.0 pin every MCP server it imported came back refused: plan
sent [[mcp]] to root/io.toml, and configure::write round-trips through
Config::discover, which refuses the section and rolls the file back.

Scope::User, and there is no choice to make — REFUSED_SECTIONS rejects
[[mcp]] in every scope a workspace can supply, and the user scope is the
sole exemption. The harness's own reasoning applies: an [[mcp]] entry is
an argv this process spawns at run start, refused where the file arrives
with a git clone or sits in a root the run's agent can write to. An
import is the operator's own act on their own machine, which is exactly
what $IO_CONFIG encodes.

The scope parameter is removed from mcp_item rather than changed, so
building an item that cannot be written is no longer expressible. plan's
scope argument now means "where instructions go" and nothing else — the
tests still pass Scope::Project and assert the item comes back
Scope::User, which is a stronger claim than switching the argument.

Everything else /import writes was already fine: instructions go through
memory::remember and the [instructions] list memory::install writes at
user scope; skills are plain files; a model and an allowlist write no
section.

Two tests were passing without testing their subject, and both are
fixed. a_secret_in_a_servers_environment_reaches_no_file_io_cli_writes
asserted refused.len() == 2 as evidence that an unresolvable ${env:} is
caught at import time — but refuse_widening runs before substitution, so
both entries were refused for the section. It now also requires each
refused line to name the variable and say it is not set, which is the
assertion that fails if the refusal reverts. And holds(workspace, SECRET)
was sweeping a directory the import no longer writes to, so it would
have passed by looking somewhere empty; the sweep now covers the
user-scope file.

Four assertions moved from reading bytes on disk to re-reading through
Config::discover, so the oracle is "the harness accepts what we wrote".
written_servers survives for the one assertion genuinely about bytes:
that a rollback left no entry behind, where no Config exists to ask.

tests/import.rs holds the env lock for exactly as long as IO_CONFIG is
set, released by the same Drop that unsets it. Without it the suite
would have appended [[mcp]] entries to the real ~/.io-cli/io.toml of
whoever ran it.

Task: US-IO-CLI-0.35.0-T06
…moved

Now that the adapter ships a copy rather than pointing into the clone, a
git pull of the marketplace is no longer visible immediately: the copy
is a snapshot taken at install time, and installing again is the update
path. That path had a defect and no reporting.

adapt::generate already rebuilt the adapter on every run, so a second
plugin add was re-copying. What was broken is what happened when the
operator declined. prepared returned made: Some(into) unconditionally,
and made means "what a declined install must take back" — on a
re-install that is an adapter an existing [[plugin]] entry already
names, so answering "leave it" to an update deleted a working install
out of a file this module never opens. prepared now records whether the
directory existed before the generator wiped it.

The disclosure gains one row naming which directories were copied, both
ends of the copy, and what refreshes it. A row rather than a Disclosure
field because both doors already print every row; an empty copied list
draws nothing, so native bundles say none of it.

The three install doors all already wrote user scope — manage::parse
resolves an unstated --scope to Scope::User and the interactive path is
the same call — which is also the only arrangement that works, since
Plugins::load drops a [[plugin]] resolving outside the workspace from
every scope but the user's. The disagreement was between the product and
the fixtures: install and f3 wrote io.local.toml, mirroring a door that
does not exist.

f7's bare Config::discover now takes the env lock: f6's fixture declares
rust-review in the user scope, which is exactly the bundle f7 asserts is
absent, so that was a live CI-only flake.

The new test installs a bundle, withdraws a skill upstream, adds
another, installs again, and asserts the adapter is rebuilt in place —
the withdrawn skill gone rather than merged, since a merged copy would
keep reaching the system prompt forever.

Task: US-IO-CLI-0.35.0-T05
This interface has drawn the backend that answered rather than the mode
that was asked for since 0.2.0, deliberately, because those are two
different facts. Until io-harness 0.74.0 the answer could still be
false: on macOS a crafted workdir re-granted write and network on / while
the backend still said MacosSandboxExec, and on Windows shell and git
spawned entirely unwrapped under a reported WindowsAppContainer.

0.74.0 makes each backend attempt a write outside its writable roots and
a dial outside its allowed egress at run start and records what
happened. The status line, the footer and /status now render that
outcome, so the boundary an operator reads is a thing that was measured.

Read from the store, because the event does not carry it.
EventKind::Sandbox has kind and backend and nothing else —
record_sandbox_step copies those two and drops the rest — so the arms
exist only in sandbox_events' detail. That puts this on the same footing
as Status::cost, which re-reads the store for the same kind of reason.
The announcing event is the trigger and the store is the payload; the
row is written once per run at step 0, so a per-step read would re-read
a row that cannot change.

The word is a field of its own rather than an addition to the
containment string, and the footer measured why. The right-hand group is
fit all or nothing, so a longer containment word does not truncate: at
eighty columns it took the posture and the containment off the row
together, and an operator lost both without being told. The gate written
for it caught that on the first run. This module has paid for that
lesson twice already — effort in 0.26.0 and branch in 0.25.0 — and both
were moved into counts for it, where narrowing takes one field at a
time. The same argument decides this one: the fact an operator can
afford to lose is the one still readable a keystroke away on /status.

verified only when both arms were attempted and both refused; anything
else is unverified. /status prints the label io-harness wrote, which is
the only surface with room to separate an arm that landed from an arm
that was never attempted.

There is deliberately no "contradicted". BoundaryProbe::contradicts_claim
needs claimed_confinement and claimed_egress_denial, and trace_label
carries neither — so io-cli cannot tell "this backend claimed
confinement and a write landed anyway" from "this backend never claimed
that arm", and drawing an alarm on the second would be a false one on
the field this release exists to make trustworthy.

A run with no containment is never probed and records no row, which is
what leaves the field absent rather than drawn as unmeasured.

Task: US-IO-CLI-0.35.0-T12
Two defects this release created, found by reviewing what the adapter
copy implied rather than by a failing test.

Making the adapter a copy made installing again the update path. Both of
these were harmless while generate only ever ran on a first install.

A refused refresh destroyed a working adapter. generate wiped `into` up
front and removed it again on every refusal — correct when there was
nothing there to lose, and destructive once an existing [[plugin]] entry
named it. It now stages in a sibling and swaps in on success, the shape
fetch::clone_at already uses, so every failure leaves the installed
adapter exactly as it was. The pid is in the staging name so two io
processes adapting one bundle cannot stage into each other. The
remaining window is one rename wide where it used to be the whole
function.

A refresh appended a duplicate entry. pluginview::add appends
unconditionally and Edit::append always splices at end of file, so every
update added a second [[plugin]] naming the same directory. io-harness
drops it with "a plugin with id `x` is already declared and switched
on", so nothing broke and nothing was said: the file just grew an
ignored entry on every refresh, for as long as the operator kept the
bundle current. plan already receives the declared set, and a refresh
has no edit to make — the adapter is regenerated on disk before plan
decides anything. The disclosure still travels, because what the refresh
moved is what the operator needs to see.

Both gates were sabotaged and both failed alone: reinstating the removal
loses the adapter and the test names the skill it can no longer read;
returning the add edit unconditionally fails on the edit count and
prints the Append that would have grown the file.

Task: US-IO-CLI-0.35.0-T04
Task: US-IO-CLI-0.35.0-T05
io-harness 0.74.0 refuses whole sections from any file that lives in a
workspace — [[provider]], [[mcp]], [[lsp]], [[hook]] in io.local.toml as
well as io.toml, ten widening values, and an absolute or ..-bearing
run.skills or run.templates. An operator who clones a repository can meet
three of those at once, and needs to know they were refusals and not
failures.

Three doors reach a refused configuration and they did not agree.
Start-up framed it; the per-turn reload handed back io-harness's bare
sentence with no root; the write door rolls back and quotes the harness.
So the same [[mcp]] in the same file read as a refusal when io started
and as an unattributed error at the turn boundary that noticed the edit
— and the reload door is the one an operator meets while working, having
just saved the file. reload now goes through configure::refusal too.

"was not accepted" rather than "could not be read". A refusal is not a
malfunction: the file parsed and io-harness declined what it said.
"Could not be read" told an operator their file was broken when it was
intact and in the wrong place, which is a different problem with a
different fix. The wording covers both cases without io-cli having to
classify which one it is — Error::Config carries a sentence and no
structure, so classifying would mean a list of the harness's clauses
that goes stale the next time it tightens, which is the mistake
widens_workspace was rewritten to stop making.

The gate drives five refused shapes through both doors and asserts
against the sentence io-harness actually produced for that fixture,
never a needle typed in the test. 0.30.1 shipped a gate whose needle was
"io does not manage" against a refusal reading "is not a surface io
manages"; it passed forever and asserted nothing.

The rustdoc on refusal was also stale: it said the harness names the two
files that may carry a hook. Since 0.74.0 it names one.

Task: US-IO-CLI-0.35.0-T13
…loor

run.templates was never unapplied — it was applied on one door only, and
docs/guide/limits.md named the wrong thing as the gap.
commands::templates has read the key through its own accessor since the
palette learned prompt templates, but only an interactive session called
it, so io exec ignored a key docs/config.example.toml documents. A
configuration key that works in the terminal and silently does nothing
in CI is the asymmetry this product deleted in 0.14.0, arriving again
through a different door.

A headless goal written as /name is rendered from the template of that
name, through the same commands::expand the palette calls with the same
empty argument list — two doors rendering one template two ways is the
divergence this exists to remove. A goal that does not begin with / is a
prompt and is passed through untouched. A name no template has is
refused before a store is opened or a provider built, rather than spent
on a call that says so.

And io-harness 0.74.0's local-address floor refuses a loopback,
link-local, CGNAT, ULA or RFC 1918 provider endpoint at run start
whatever the policy says, lifted only by an environment variable the
harness deliberately gave no configuration key. Everyone pointed at
Ollama, LM Studio or llama.cpp breaks on this pin, and a bare refusal
with no remedy is the most user-visible break in the release.

The remedy is attached by matching the layer io-harness stamps on the
refusal, not by inspecting the endpoint. io-cli could decide for itself
which shape an address is; it does not, because 0.30.0 shipped a copy of
one of this dependency's address checks and it failed open on five
shapes, including a URL where a bracketed host swallowed the real one. A
copy's test table is written from the copier's imagination rather than
from the original's bug list. Reading the layer is right for every shape
the floor refuses now and every shape it gains later.

FLOOR_LAYER is pub(crate) upstream so the string is spelled here, and a
test holds that spelling against the locked source. A refusal from any
other layer is left exactly as io-harness wrote it.

io-cli sets the variable nowhere, and a source-text gate says so: the
lift is the operator's explicit choice, and a product whose thesis is
that the boundary is legible must not re-grant from the inside a
permission the harness has just taken away.

Task: US-IO-CLI-0.35.0-T10
Task: US-IO-CLI-0.35.0-T11
Seventeen io-harness citations in src/ pointed at 0.73.0. Every one was
re-verified against the 0.74.0 tree rather than renumbered: the two trees
were diffed first, so a citation into a byte-identical file could not
have moved, and each remaining line was located by its own content.
pricing.rs and skills.rs are identical between the pins; config.rs
moved four citations by between 31 and 569 lines, and the rest by
between 0 and 29. A version bump alone would have left ten of them
pointing at unrelated code.

The prose sweep is not a version bump either, and doing it as one would
manufacture false history. Most references to an older harness in the
guide are past-tense and still true — "io-harness 0.70.0 closed it",
"0.72.0 or earlier does not know the key", the release that introduced a
key. Two were present-tense claims about the pinned version and only
those moved: docs/CONTRACT.md on [[bin]] and docs/guide/skills.md on
read_skill.

docs/guide/plugins.md described the pre-0.35.0 adapter — "the paths in it
are absolute and point into the clone" — which is now the arrangement
io-harness refuses. It describes the copy, and says that installing
again is the update path, which nothing said before.

docs/guide/limits.md named run.templates as the one unapplied [run] key.
It was applied, on one door.

The CHANGELOG's 0.34.1 entry still says a boundary refusal exits 1. That
is left alone deliberately: 0.30.2 established that a changelog is a
diary and is excluded from every present-tense sweep, because it has to
be able to say what used to be true. The 0.35.0 entry records the fix.

Cargo.toml goes to 0.35.0 by hand — the workspace declares no
version_files, so nothing checks it.

Suite: 103 binaries, 1863 passed, 0 failed, 37 ignored, from 102 / 1831 /
37 at 0.34.1.

Task: US-IO-CLI-0.35.0-T14
Task: US-IO-CLI-0.35.0-T15
The live suite is not run by cargo test, so tests/live.rs was in no
migration wave and nothing offline could have caught either of these.

gated() built its fixture with Config::from_toml, which hard-codes
Scope::Project — where io-harness 0.74.0 refuses a [[provider]], and a
rubric judged by a second model has to name one. It goes through the
user scope like every other fixture in this repository now.

And the fall-through test's premise stopped being true.
live_f5_an_unreachable_primary used http://127.0.0.1:9/v1 on the
reasoning that nothing answers there, so the failure is a refused
connection, which is Transport and therefore retryable. Under 0.74.0 the
local-address floor refuses a loopback endpoint before any connection is
attempted, whatever the policy says — so the primary failed with
Error::Refused, which is not retryable, and the chain stopped instead of
falling through.

That is correct behaviour and worth stating: a boundary refusal that
quietly tried the next vendor would be the fall-through working around
the thing that said no.

There is no routing around it either. The floor also refuses a name it
cannot resolve — "a name that will not resolve is not checkable" — so an
.invalid host is a Refused too, not a transport failure. Reaching a
transport failure at all needs an address the floor permits, and the only
fast deterministic one is a loopback port with nothing behind it. So the
test sets IO_HARNESS_ALLOW_LOCAL_ADDRESSES for itself, which is exactly
the deliberate choice the harness means an operator to make. src/ still
sets it nowhere, which f11 asserts: a test choosing it for itself is the
operator's choice; a product choosing it for the operator is not.

Live: 34/37 before these two, 36/37 after — the remaining failure is
live_a_real_key_is_accepted on a provider Timeout, which passes alone in
1.44s. Both numbers recorded, because a flaky live gate reported only at
its second attempt is a gate nobody can see.

Task: US-IO-CLI-0.35.0-T02
Task: US-IO-CLI-0.35.0-T11
…lop/softprops/action-gh-release-3.0.3

chore(deps): bump softprops/action-gh-release from 3.0.2 to 3.0.3
CI caught this on Windows and nothing else could have. Git checks the
source out with CRLF there, so the gate's "\n}\n" needle matched nothing
and it failed on the expect rather than on its subject — green on macOS
and Linux, red on the one platform that sees it.

0.33.0 shipped exactly this shape in tests/docs.rs's paragraph splitter,
where a page naming eleven commands was reported as naming all
thirty-eight. The rule it left behind is to normalise where the bytes are
read rather than per helper. This is the first source-text gate written
since, and it repeated the mistake.

The other three source-text gates added in this release were checked and
are safe: two go through str::lines, which strips the carriage return,
and the third reads the harness source through support::harness_source,
which already normalises.

Proven the way that lesson says to prove it — by converting
src/configure.rs to CRLF locally and running the gate against it, since
an LF checkout cannot exercise the failing condition at all.

Task: US-IO-CLI-0.35.0-T07
@aakashpawar1999
aakashpawar1999 merged commit 64181f1 into main Sep 2, 2026
13 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