Skip to content

io-cli 0.41.0 - #114

Merged
aakashpawar1999 merged 25 commits into
mainfrom
develop
Sep 12, 2026
Merged

aakashpawar1999 merged 25 commits into
mainfrom
develop

Conversation

@aakashpawar1999

Copy link
Copy Markdown
Contributor

Everything the 0.40.0 field report found, fixed on io's side — and the permission surface stops being the thing that blocks you.

The permission half

A refusal that came from a default now raises the approval overlay instead of refusing. This is on out of the box, with [app.io-cli] escalate = false to turn it off.

What makes that safe is the floor rather than the switch: a default is not a layer. A deny that came from a [[policy.layers]] rule is never escalated, never drawn and never asked, and neither is a path that escapes the workspace root. Escalation moves a fallback — it changes when io asks, never what io permits.

The overlay's answer gains a third scope: y allows the call, a allows it for the session through the existing in-memory remembered layer, w allows it and writes one [[policy.layers]] rule into the user-scope file, showing the exact text before it lands. n denies. /policy lists every rule in force with the file that declares it and marks the ones io wrote; /policy revoke removes one of those and refuses a rule io did not write, naming the file to edit instead. The door that opens is the door that closes, in the same release.

--full-access runs a session unconfined in one word — the only thing that reaches a bind() the sandbox denies below policy. It is deliberately not in the Shift+Tab cycle, and it is never written to a file.

The field report

Gates: config set … -- words stores a list and a quoted string is refused; gates.retries is honoured by the headless driver; a failed gate quotes its output and exit code from io-harness 0.86.0's new gate_output; a conversationally-answered run says the gate was not evaluated rather than leaving a pass indistinguishable from a no-run.

-C on a path that names nothing is an error. config get on a missing key exits 1. Bare io resume lists, and says so when nothing is parked. io mcp get inspects. provider.kind, provider.model and provider.base_url are readable. io exec --json ends with a cost line. --sandbox is accepted on either side of the subcommand. Every subcommand's --help names its verbs. A slash command typed in full runs on the first Enter, the palette matches /contain on typed in one go, and its ⋯ N more counter counts what is actually hidden. /status says when its window figure is a fallback. A contained turn's sandbox label reads the child's posture by event depth. A finished child says what it concluded. A server's stderr is readable instead of polluting io's. Ctrl+E puts the running step's full text into the scrollback.

io setup now asks about the fan-out: a fresh install could not decompose a turn at all and nothing said so. Declining is the default and writes nothing, so an install that says no produces the file 0.40.0 produced. Both doors onto [app.io-cli.containment] are rendered, parsed back and compared in a test, so they cannot drift.

Dependency

io-harness pinned to 0.86.0, up from 0.83, across three releases. Breaking changes and filed issues read, Provider's method list diffed against the extracted 0.83.0 tree for a method added with a default body.

Verification

cargo test --all-targets — 109 binaries, 2,085 passed, 0 failed, 42 ignored. cargo test --doc — 1 passed. cargo fmt --all -- --check and cargo clippy --all-targets -- -D warnings clean. Every new gate was sabotaged and watched to fail on its own arm, and the argv-door criteria were driven against the built binary — which is how the bare-io resume silence above was found.

dependabot Bot and others added 25 commits September 6, 2026 23:42
Bumps the patch-updates group with 1 update: [toml](https://github.com/toml-rs/toml).


Updates `toml` from 1.1.4+spec-1.1.0 to 1.1.5+spec-1.1.0
- [Commits](toml-rs/toml@toml-v1.1.4...toml-v1.1.5)

---
updated-dependencies:
- dependency-name: toml
  dependency-version: 1.1.5+spec-1.1.0
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: patch-updates
...

Signed-off-by: dependabot[bot] <[email protected]>
Both files named 0.38.2, so they skipped 0.39.0 entirely — for anyone
installing through brew or scoop, two sets of release notes described
versions they could not get. Regenerated by scripts/update-tap.sh from the
published Release's own SHA256SUMS.
Point the tap and the bucket at 0.40.0
…h-updates-fe1561a707

build(deps): bump toml from 1.1.4+spec-1.1.0 to 1.1.5+spec-1.1.0 in the patch-updates group
Three releases of pin in one step, audited against the published source
rather than the changelog.

What the bump cost, each verified rather than predicted:

- `Error::Provider` gained a `rate_limit` field and the variant is not
  `non_exhaustive`, so four exhaustive construction sites take
  `rate_limit: None`.
- `EventKind` went 57 to 61. `triage::TRIAGE` and `acp_map::MAPPING` each
  take four rows. `prefix_broke`, `cache_miss` and `rate_limit` are
  deliberate silences routed to the two machine surfaces, because the
  operator surface they belong on is 0.43.0's and half of it built early
  would be worse than none of it. All four are declared unconditionally,
  so "we do not enable that feature" is not protection.
- `TOOL_KINDS`, `RunOutcome` and the `Provider` trait are unchanged. No
  wrapper inherited a defaulted method this time, and no count literal
  exists in this crate to rot.
- Every `io-harness-0.83.0/` citation is re-anchored. Only `mcp.rs` moved
  its cited lines; the rest are proved byte-identical with `diff -q`
  rather than assumed. The bare `src/....rs:N` form is swept too, since
  `f19` checks a cited version and never a cited line.

And the release's first real fix. io-harness 0.86.0 adds
`EventKind::GateOutput { output, exit_code }`, so a criterion that ran and
did not hold can finally say why. Before this a failing gate emitted two
events carrying no payload at all, and an operator's first gate could fail
permanently with the cause sitting in a store row they had no reason to
open.

The contentless `Sandbox { kind: "gate_output" }` line is folded away: it
announced that a diagnosis existed without being one, and keeping it beside
the row that carries the text would print two rows for one fact. The
string is io-harness's and is not re-bounded here — it arrives merged over
both streams and cut to 4,000 characters from the head and the tail, which
is a better rule than this renderer would have reached alone. A gate that
printed nothing says so, and one killed by a signal or a cap is named as
killed rather than given an invented exit code.

One behaviour change the suite found and the audit could not.
io-harness 0.85.0 made a run's prompt append-only between folds, because
re-reading the memory block every turn rewrote the earliest user text and
threw the vendor's prefix cache away for the rest of the run. A note a run
writes about its own work therefore never enters that run's own prompt, so
it draws no recall row. `tests/recall.rs` asserted the old behaviour; it
now asserts the new one and says why.
All three were found by driving the shipped 0.40.0 binary the way an
operator drives it, and all three share a shape: io understood the command
badly and reported that it had gone well.

A quoted command line is refused. `config set app.io-cli.gates.command
"python3 --version"` stored the single argv element `["python3 --version"]`
and exited 0, so `execvp` was handed a binary whose name is the whole
command line and the gate failed on every step of every run — fourteen
failures in one observed run, while the agent's own `exec python3` returned
0 beside them. The refusal names the `--` form, which is what 0.40.0 added
and what this shape silently bypassed.

The guard reads whether `--` was used, which is why it is precise rather
than a heuristic: after `--` the operator has spelled the vector out, so a
single element containing a space is a program path and is taken at its
word. The refusal therefore costs nothing expressible.

`config get` on a key that does not exist exits 1. It exited 0, so any
script probing for a key took the success branch for a key io has never
heard of. The line printed is unchanged — only the code beside it moved,
which is the half a script branches on. A catalogue key that no file sets
still exits 0 and says `default`, because "nothing set it" and "there is no
such key" are different answers and collapsing them would make the new code
useless.

`-C` on a path that names nothing is refused, and creates nothing. It used
to make the directory and work inside it: the agent reported the new `pwd`,
wrote files there, and the run reported success, so a mistyped workspace
quietly became a real one and the work went somewhere nobody would look for
it again. The check sits above `home::adopt` and the discovery under it,
because both derive their own paths from this one and neither can refuse
it. A `-C` pointed at a file is refused too.

Exit `1` and not `2` for all three, against the contract's own table: `1`
is a command line that was not understood and `2` is a boundary refusal.
The table's first line is that a code never changes meaning, so the
criteria were amended to it rather than it to them.

Each fix is driven through the real argv in `tests/manage.rs`, because none
of these refusals exists below the door — the `--` guard reads a fact about
the argv that `configure::source_for` never sees, and the other two are
exit codes. That seam has now caught something in six consecutive releases.
The session has honoured `[app.io-cli.gates] retries` since 0.24.0, through
`gates::may_retry`, with an operator watching either way. `io exec` never
has. It runs one turn and keeps no loop of its own: it hands io-harness a
contract carrying the criterion, io-harness evaluates that criterion after
every step, and nothing between the two was counting. So a mis-set gate did
not fail fast, it failed on every step until the gated step cap of forty.

A field pass measured the cost: twenty-one gate attempts across sixteen
steps over more than fifteen minutes, every one of them a paid completion,
before the run was killed by hand. It is the only defect in this release
that costs money per occurrence.

`gates::Budget` is an observer because io-harness publishes no per-gate
budget. `TaskContract::max_retries` sits next to one in the same file and is
not it — that bounds a failing provider or tool step before the run
escalates the error, so borrowing it would cap the wrong thing for the wrong
reason. What io-harness does publish is `Flow::Cancel` from an observer, so
the budget lives where the events are and io-harness stays the only thing
driving the loop.

Cancelling does not lose the exit code, which was the thing to check before
choosing this mechanism. The run returns `RunOutcome::Cancelled`, but
`exec::verified_code` reads the gate's standing ahead of the outcome and
answers `UNVERIFIED` for a `GateOutcome::Failed` whatever the run says, so a
CI job still reads `6`. A bound that silently changed an exit code would
have been a worse bug than the one it fixes.

`retries` is a number of retries, so the budget is one more than it: `0`
allows the single attempt every gated run makes and `1` allows two. That is
the reading `may_retry` already gives it and the sentence `/gates` already
shows.

Only `gate_phase_failed` is counted. The `gate_output` beside it is the same
attempt reported a second time with the text in it, and counting both would
halve every budget an operator writes.

On both headless doors. A resume is what an operator reaches for after the
first door ended badly, and a fix landing on one of these and not the other
is the shape 0.38.1 paid for; `contract::gate_budget` is the single reader of
`retries` so the two cannot disagree.

And the run says why it stopped. `describe` renders a cancelled run as "was
cancelled", which is true of the mechanism and wrong about the cause —
nobody pressed anything. Naming the number that ran out is what turns the
stop into an instruction: the criterion is wrong, or `retries` is too low.

The unit arm drives the counter with its own events. It cannot prove the
door attaches the observer, so the arm that reaches a real `io exec` belongs
in the live suite.
…sing

The thing operators hit hardest is a dead end: the agent is refused, the
train of thought ends, and the only cure is to leave the session, edit a
file and come back. This makes that refusal a question — but only where
io-harness was answering from a fallback.

`[app.io-cli] escalate` is the switch and its absence means ON. That is the
one key in this section whose absence is not "behave as the release before",
and it is deliberate: an operator who has to find a key never meets the fix,
and the frustration is the thing being fixed. `false` restores 0.40.0
exactly.

What makes it safe is the floor, not the switch. `approval::asking` takes an
`io_harness::policy::Defaults` and turns each `Deny` into an `Ask`. A
`Defaults` is what applies when no rule matched, so every `[[policy.layers]]`
rule is untouched and io-harness resolves the policy afterwards exactly as it
always has. An explicit deny still refuses, silently and always; deny still
beats allow across layers; and the path that escapes the workspace root is
not a policy verdict at all, so nothing here can reach it.

The distinction the security argument rests on — a default is not a decision
— is therefore structural rather than something a test has to catch. This
function cannot see a layer, so it cannot escalate one.

That also rules out the implementation this was nearly written as. Reading
`Policy::check`'s `Effect` and turning a `Deny` into an approval would have
made every explicit deny askable, which is the opposite of the claim, and it
would have passed any test that only checked the grant works. `Verdict` does
carry the deciding layer, so that version could have been made correct — but
it would have been a second policy evaluation living in io-cli, which
`workspace.yaml` forbids.

`Ask` and not `Allow`: the operator answers, and nothing is widened on their
behalf. A posture still decides the defaults and escalation runs after it, so
`read-only` asks about a write rather than refusing it outright and an
operator who answers no is refused exactly as before.

It goes in `session_policy` because that is the one function all ten sites
that build a turn's policy already call, and because its own documentation
already carried this argument for the posture: "a layer that denies a secret
is not a default, so no posture can unlock what a layer refused."

F7 asserts the pair. Both sabotages were run: granting instead of asking
fails the positive arm, and escalating layer rules as well as defaults fails
the `.env` arm — which is the failure the contract named as this release's
largest risk.
A sandbox refuses some calls structurally rather than by policy. A `bind()`
is denied at every posture because the backend gives the process no route to
make one, so an operator whose task genuinely needs it had nothing to reach
for short of editing two sections of a configuration file — and the field
pass hit exactly that, with `socket().bind()` failing `Operation not
permitted` under a policy that allowed everything.

`--full-access` is the one word for an operator who means it. It is global,
so it works on either side of a subcommand.

Two halves, because they are two axes. `approval::UNCONFINED` replaces the
four tier defaults with `allow`, which is what the agent may attempt.
`contract::unconfined` puts the turn on `ExecMode::FullAccess` through
io-harness's own `with_full_access`, which is what the sandbox lets a
command that ran actually do. Only the second reaches a `bind()`; setting
one without the other would give an operator who asked for full access a
session that still refused the call they asked for it to make.

Five things keep it safe and each was defeated separately:

It is not a fourth `Posture`. `Posture::ALL` still has three entries and
`Shift+Tab` still cycles three, so the widest grant in the product is not
one keypress from `read-only` — by construction rather than by anybody
remembering to leave it out. `Posture::FULL_ACCESS` is an associated
constant beside the three, shared by the flag, the status field and the
headless door so those cannot drift.

It replaces the tier defaults and nothing else, so it cannot unlock what a
`[[policy.layers]]` rule refused. `.env` and `*.pem` stay denied under full
access exactly as under every posture.

It is marked on every frame it is in force. Without the marker the field
reads `policy:custom` — `Posture::of` does not recognise all-allow as one of
the three — which is true of the struct and useless to a reader. The one
thing worse than an unconfined session is one that looks ordinary.

It is never written to a file by io, so it cannot be left on by accident or
committed into a repository, and it cannot be requested by the agent.

`contract::unconfined` is a sibling of `buying` and `masking` rather than a
parameter of `contract::session`, for the reason those two are: `session` is
held to reproducing io-harness's `default_contract` field for field when
nothing is configured, and a sixth parameter would put a branch inside the
one function that gate exists to keep branch-free. Applied at the turn door
and the resume door both.

F8 asserts four of the five and both sabotages were run. The contract's own
criterion said `Shift+Tab` four times returns to `workspace`; four presses of
a three-cycle land on `ask-writes`, so the criterion would have failed
against correct code and invited being weakened. It is six now — two full
turns, visiting every posture twice.
…elog

`docs/guide/limits.md` gains the section this release most needs a reader to
have: the three places a refusal can come from, and which one asks. A
default asks; a `[[policy.layers]]` rule you wrote refuses silently and
always; a path outside the workspace root refuses and nothing lifts it. The
middle row is the floor and it is stated as such — a default is io-harness
answering because nothing said otherwise, and a layer rule is a decision you
wrote down, so io asking whether to ignore it would make writing it down
worthless.

It says plainly that this release widens nothing a configuration file could
not already express: it changes when io asks, never what io permits. And the
last row names its cure rather than leaving it as a dead end — the agent
cannot reach `~/.io-cli/io.toml` through the file tools at any posture, full
access included, so `/config` is the surface that exists for it.

The same page records what the pin brings and what it still does not: a
provider error body is redacted at the source, with the three shapes that
pass cannot reach stated rather than implied; the shell tool's refused
constructs are carried in its own description so the model is told before it
tries, and io-cli holds no copy of that list; and a shell-made change can be
put back except an in-place edit.

`docs/guide/store.md` gains the undo half of that, with its limit beside it.
`sed -i` leaves no restore point and io-harness says so rather than guessing
at what such a command touched, so the operator gets the existing fourth
sentence — nothing changed, because the previous contents were not kept.

`docs/CONTRACT.md` gains `--full-access` with the five properties a script or
a reviewer may depend on, the `-C` refusal, the `config get` exit code, and
the list-key form. `docs/guide/configuration.md` and the contract's key table
gain `escalate`, and the counts beside them move by one.

The changelog leads with Upgrading, because six things change for somebody
who already runs io — escalation being on, `io resume` listing, `config get`
exiting 1, `-C` refusing, a quoted gate command being refused, and a note the
agent writes no longer reaching its own run, which is io-harness 0.85.0's
append-only prompt rather than anything io decided.

Version 0.41.0.
…rent

During a fan-out the parent and every spawned child emit a `Contained` event
of their own, and this line took whichever arrived last. So a parent's word
could describe a child's act: a field pass read
`read-only/macos-sandbox-exec` off the status line while a shell
`echo >> a.txt` running in the child succeeded and changed the file on disk.

For an interface whose whole claim is showing what it is allowed to do, a
label that contradicts what just happened is the worst available bug class,
and it is worse than drawing no label at all.

The event's own `depth` decides now, not its arrival order, and the deepest
run wins — during a fan-out the acts an operator is watching are the
children's. A parent's event arriving after a child's no longer takes the
field back, which is the ordering that produced the reading above.
`forget_run` clears the depth beside the word, so the next turn cannot
inherit a child's posture.

The other half of that field report entry is NOT io-cli's and is recorded as
excluded rather than quietly dropped. `nothing has changed in N steps` is
io-harness's `EventKind::Replan`, emitted after
`progress.step(contract.stall, step_changed, …)`. `step_changed` is
accumulated inside io-harness's own tool dispatch from each tool's `changed`
flag, and `src/tools/shell.rs` sets it nowhere at 0.86.0 — the word `changed`
appears in that file only in argv-validation messages. io-cli receives the
`Replan` and never the input to it, so a shell write is invisible to the
stall detector from this side of the API. Read out of the pinned source
rather than inferred from the symptom, and to be filed upstream with the
field report's own reproduction.
The status bar has shown money since 0.22.0 and `/cost` reports per run, per
session and per install. `io exec --json` carried none of it: a sweep of a
whole run's stream for `cost`, `usd` or `price` returned nothing, on the one
surface — CI — where a budget signal matters most.

The line is a JSON object like every other, keyed `"event": "cost"`, carrying
`total_cost_usd`, `unpriced_calls`, `calls_without_usage`, `calls` and
`run_id`.

`total_cost_usd` is `/cost`'s own figure for that run because it is the same
computation over the same rows — `Store::provider_calls` priced by
`Total::of` — rather than a second implementation. The criterion is equality
with `/cost`, and the only way to hold that through later changes is to share
the source; a reimplementation agrees on the day it is written. The test
asserts it against a real store with a deliberately unpriced call in it.

`unpriced_calls` rides beside the money and is not decoration. A run with
unpriced calls is reporting a floor, not a total, and a consumer that reads
the money without it is being lied to by omission.

There is deliberately no per-step `cost_usd`, and the reason was verified
rather than assumed. `PriceTable::cost_micros` charges for
`server_tool_requests`; `EventKind::StepUsage` does not carry that field, so
a figure derived from the event under-reports every step that used a
provider's own server tool. The store's `ProviderCall` row does carry it and
is keyed by step — but it cannot be read where the lines are written, because
`Store` is `!Sync` and the writer is an `Observer` that lives across the whole
run. A knowingly-low money figure per line is worse than no figure, on the
surface this product is most praised for being honest about. It is an
io-harness ask for `server_tool_requests` on `StepUsage`.

And a correction to my own prose in the previous commit. Three shipped pages
said an escalated refusal could be answered "for good" — written down into a
file. It cannot: the answers are the existing `y` for the call and `a` for the
rest of the session, both held in memory. The third scope was designed and is
not built, and it moves to the next release together with `/policy list` and
`revoke`, because a grant that can be written but not un-written from the same
surface is exactly the trap that pair was decided to avoid.

`tests/docs.rs` gains `n7`, which sweeps every shipped page for an escalation
paragraph promising an answer that outlives the session. It was written after
the claim shipped, and it was verified by putting the sentence back and
watching it go red — a permission an operator believes is written down and is
not is the wrong direction for that claim to be wrong in.
A field pass set a gate of `["false"]`, ran `io exec "reply FA"`, watched it
finish clean, and concluded that gates only fire when files changed. That is
wrong about the trigger and wrong about the cause.

`reply FA` is a prompt that is only a question. io-harness classifies those
and answers them in one completion — no steps, no tools, no verification —
and `contract::configured` turns that classification ON for a gated contract
on purpose. The reason is written where it is done: otherwise attaching any
criterion makes `hello` open a full agent run that executes the operator's
test suite after each of its steps. Nothing about a changed file is involved
at any point.

So the behaviour is right and only the silence was wrong. An operator who
configured a gate and watched a run finish without it had no way to tell a
gate that passed from a gate that never ran, and that is the one distinction
a verification surface may never blur. It now says so, and names
`[app.io-cli] conversational = false` — the key that already existed for
exactly this — rather than describing the classifier.

Read from `TurnKind::Reply` and not from a step count. io-harness publishes
the classification it made; counting steps would be this crate guessing at
it, and a run that legitimately finished in zero steps is a different thing
from one that was never opened. `tests/contract.rs` already holds the driver
to reading the kind, which is what caught the first draft of this.

The contract item is amended rather than silently satisfied: it asked for the
gate to run on a turn that changed no file, and the premise under it does not
hold.
Seals the 0.41.0 release record: status released, mode published, evidence
stored, eight known limitations recorded, the record pinned in
`.ultraship/releases.lock` and `ultraship validate` green over 134 checks.

`.ultraship/` is gitignored whole in this repository, so the record and its
evidence live on disk and never in the history. Said here rather than left to
the absence to imply it was never written: it was written, before the merge,
before the tag and before the Release, which is what keeps it a statement
about what was verified rather than a description composed with the answer in
hand.

The one code change is a defect the pre-seal smoke found. `--full-access` is
a `global` arg, and its doc comment was `///` — which is clap's help text, so
the whole rationale printed on every subcommand's `--help` page. The
reasoning moved to `//` and the help line is one sentence. 0.40.0 made
exactly this correction to `--profile` and the comment there says why; the
new flag repeated it within one release.

Gate numbers for the next release to compare against: `cargo test
--all-targets` 109 binaries, 2,062 passed, 0 failed, 42 ignored; `--doc` 1;
`cargo fmt --check` and `cargo clippy --all-targets -- -D warnings` both exit
0; ten direct dependency names plus libc and tempfile.

The release is materially smaller than its contract and the record says so in
its first known limitation, with the twenty scope items that did not ship
named one by one. The contract's own fallback scope was met, and the outcome
block carries a header pointing at `delivered` so the gap is legible rather
than tidied away.
Seven findings from the same field pass, all on the shell door and all the
same shape: the binary invited a question and then would not answer it.

`io resume` with no argument lists. The top-level help has said since 0.23.0
that resume will "list the runs parked in the store, or carry one of them
on", and bare `io resume` answered `the following required arguments were not
provided: <RUN_ID>` — the help page contradicting the binary, with the
listing reachable only through a `--list` that same page never mentioned.
`--list` stays as an alias because it is in shipped scripts and in this
repository's own guides.

`--sandbox` is global. `-C`, `-m`, `--profile` and `--plain` were all
accepted on either side of the subcommand and this one was not, so
`io --sandbox full-access exec "…"` failed while every neighbour worked —
which `Cli::dir`'s own note calls a flag that works only on its author's
machine.

Every subcommand's `--help` names its verbs. `Manage` carries one
`trailing_var_arg` field, so clap has no subcommand list to enumerate and
printed none: the only way to discover `probe` was to type a wrong word and
read the refusal. The verbs are in the variant descriptions now, and a gate
holds those and `manage::verbs` to naming the same set, because two places
for one fact is how a help page starts offering a word the binary refuses.

`io mcp get` inspects. It printed the line `list` prints — no command, no
args, no env — while the surface's own summary promised "Add, list, inspect,
change or remove". It shows the command, the arguments and which environment
variable each entry reads, and it echoes no value: a `${env:NAME}` is drawn
whole because that is what tells an operator which variable to export, and a
literal reads `set (value not shown)` rather than landing in a terminal, a
scrollback and a CI log at once.

`io mcp list` with nothing configured says so, on stderr so a script still
reads zero rows. It printed nothing at all, which at a terminal is
indistinguishable from a verb that hung.

`provider.kind`, `provider.model` and `provider.base_url` are readable.
`io config get provider.model` answered `no such key`, so a script had no way
to ask which model was configured. They are read-only, because `/provider`
and `-m` already own that write. The origin was the half that was nearly
wrong: io-harness records an origin for `provider` and not for its leaves, so
the row fell through to `default` and named a crate default as the source of
a model the operator had written in a file — the failure `configure` exists
to prevent and that `home::origin` shipped once in 0.15.0.

`config unset` takes the header with the last key in it. An emptied
`[app.io-cli.gates]` is not neutral: a gates section naming no kind is
refused rather than read as "no gate", so the leftover header turned a key
the operator removed into a configuration that would not resolve.

That last one was first written inside `Edit::unset`, and a test named
`unset_of_the_only_key_leaves_its_section_standing` caught it — `unset` names
a key, `remove` names a region, and `tests/edit.rs` keeps them apart on
purpose because collapsing them is a destructive ambiguity. The primitive is
untouched; the *verb* composes the two, which is where the operator's intent
is known. One edit or the other and never both: the removal's span covers the
key's own line, so planning both makes two splices over the same bytes, which
`apply` refuses — and the whole write would then do nothing, which is the one
outcome this verb must never have.

`configure::READ_ONLY` is a third list beside `EXCLUDED` and
`USER_SCOPE_ONLY`, read by `source_for` rather than only by a test — the
correction 0.40.0 made after `EXCLUDED`'s reason strings turned out to have
no reader at all. The every-key-has-a-kind gates consult it, so a read-only
key is exempted by being named rather than by the gate being weakened.
A slash command typed in full runs on the first Enter. Typing `/cost` and
pressing Enter accepted the completion — replacing `/cost` with `/cost` — and
did nothing else, so it took a second press. It is consistent once learned
and every new operator types a command, presses Enter, and watches nothing
happen. Only an exact match submits, so a partial query still completes,
which is the whole point of a palette.

`/contain on` typed in one go matches `/contain`. The palette ranked the
whole string against bare command rows, and `contain on` is not a subsequence
of `/contain` — so io's own notice told an operator to type a form io then
refused, and the sequence that worked was one nobody would guess.

The command half is what is matched, and Enter still hands back the whole
line. Matching alone would have been worse than the papercut: choosing the
row puts `/contain` in the composer and drops the ` on` that was typed.

A slash typed into an open picker says where it went. The picker owns the
keyboard while it is open, so `/memory` over the model picker silently became
a filter and left `No row matches "/memory"` with nothing to explain it — the
command looked broken rather than captured. The picker names itself and says
Esc first. The palette is exempt: a slash there is the ordinary thing to
type.

The `⋯ N more` counter moves. It counted everything the query admits less
what fits on screen — a constant for a given list and terminal — so a field
pass read `⋯ 53 more` on the first row of fifty-eight and `⋯ 53 more` again
sitting on the last one. It counts below the window now: it falls as you
scroll and is gone when the end is visible. Below the window and not below
the cursor, which is the near miss — counting from the cursor makes a list
that fits entirely on screen claim a row it is already drawing.

And the no-TTY message stops dating itself. It ended "`io exec` and a
non-interactive mode are 0.5.0", written when both were forthcoming and still
saying so thirty-five releases later; a field pass met it on 0.40.0 and read
it as a stale build.

`tests/dependencies.rs` gains `n7` to stop that returning, and its first
draft was VACUOUS — found by putting the bad sentence back and watching it
stay green. It collected quote pairs per line, and this crate writes long
messages as one literal continued across lines with a trailing backslash, so
the line carrying `mode are 0.5.0."` opens no quote of its own and matched
nothing. It sweeps the comment-stripped file whole now.

The gate is narrowed to phrasing that PROMISES rather than any phrasing that
mentions. `servers::OLDER_BINARY` says an `enabled` key is understood "from
0.29.0" so an operator knows whether a colleague's older binary honours it —
a compatibility fact that is useful precisely because it names a release, and
one this repository already holds to its own shape. What is always wrong is a
message dating a capability as though it were still ahead of the reader.
…place

An approval gains a fourth answer. `w` — "allow and write it down" — appends
a `{ act, effect = "allow", pattern }` rule to a named layer in the operator's
own configuration, so the permission is there in the next session and in every
one after it. `y` and `a` are unchanged.

`w` rather than moving `n` to make room: the letters are the words rather
than shortcuts for them, and `n` is the one key an operator presses without
reading.

The decision and the write are separate acts. `approval::decision` answers
the same thing for `Always` that it answers for `Session`, because a decision
that depended on a file being writable would make a turn's outcome depend on
a disk. The turn is allowed either way; the driver writes, and a refusal
comes back as a sentence rather than as a denial.

And `/policy` is where it comes back off. A grant that can be written from
one surface but only un-written by editing a file is a trap, so the release
that opens that door closes it: `/policy` reports the posture, whether a
default's refusal asks, and every `[[policy.layers]]` rule in force with the
layer that carries it; `list` is the same listing alone; `revoke` takes one
away; `full-access` is the widest grant in the product and goes through a
confirmation.

io un-writes only what io wrote. Every rule `revoke` offers sits in the
`io-remembered` layer, which nothing but a `w` answer puts a rule into. A
rule in any other layer was written by a person — deliberately, in an editor,
very possibly to DENY something — and taking one away on a keystroke would
let a permission surface delete a permission boundary. The predicate is the
layer and never the rule's shape: an operator is entitled to hand-write an
allow that looks exactly like one io writes, and matching on shape would
offer to delete it.

`/policy` takes the product's last free slot, in `Configure`. Every group is
full now, so the next command to want a home re-files something that is in
the wrong group — the rule 0.25.0 wrote and four releases have followed since
— rather than widening the bound. It is refused mid-turn for the reason
`/config` is: it writes a configuration file.

The list is read through `Config::policy()` rather than out of a file,
because a file is one scope and what is in force is the merge of three; a
listing of the nearest would tell an operator a rule had gone while a wider
scope still carried it.

Seven gates had to be told about the thirty-seventh command, which is the
point of having them: the written-out inventory, the group occupancy, the
mid-turn refused list, the README and guide tables, the keys page's refused
list, the palette's narrowing count, and the driver's count of confirmed
configuration writes. Each now names 0.41.0 and what it added.
…ens at

`App::answer_approval` returns the rule an `always` answer means and is
`#[must_use]`. That type has no configuration, no root and no writer — it
draws and it remembers — so an answer whose meaning is *write this down*
cannot be finished inside it. Handing the rule back makes the driver's
obligation structural rather than something a later reader has to notice,
which is the shape `answer_intent` and `decide_plan` took in 0.23.0 for the
same reason.

It comes out as `Command::Remembered`, and the arm that matters is the one
in the turn loop. An approval only opens while a turn is in flight, so that
loop — not the idle one — is where the answer arrives, and its match ends in
a `_ => {}`. Left there the rule would have been swallowed in silence: the
operator would have read "allowed, and written to your own configuration" in
the transcript with nothing written anywhere, which is a permission they
believe is recorded and is not. The idle loop has an arm too, unreachable in
practice and written rather than ignored.

The workspace root is snapshotted before the turn borrows the session. The
turn holds `&mut Session` for the whole select loop, so `session.root()` is
not askable while a question is on screen; it is a path and it does not move,
which is the same reason `TurnFacts` beside it is honest.

User scope or nowhere. A `[[policy.layers]]` rule inside the workspace is one
a `git clone` hands to everybody and one this run's own agent can write,
which is exactly why io-harness refuses a widening from a workspace file. An
operator whose only writable scope is the workspace is told, and the
allowance still holds for the session — what failed is the part meant to
outlive it, and that is said rather than swallowed.

`tests/structure.rs` counts the driver's configuration writes and now reads
twenty-two. Both new doors are confirmed: `/policy revoke` writes on a picker
row being chosen, and `remember_rule` is reached only from an answer the
operator gave to a modal that was blocking the run.
`/expand` shipped in 0.14.0 without a key, and `src/commands.rs` recorded the
reason: a key is cheap to add later and expensive to take back once it is in
anybody's fingers. This overrides that decision, because later is now.

The moment a step's full text is worth reading is while the step is running,
and reaching `/expand` then means opening the palette and typing into a
moving screen. So the one surface that exists to show more of a running step
was hardest to reach exactly when it mattered.

`Action::Expand` is the eighth rebindable action and behaves like the other
seven: named in `[app.io-cli.keys]`, refused if it is pointed at `Ctrl+C`,
and documented with its default in the table the guide and the help share.
`/expand` keeps working and keeps its argument — the command takes a step
number and the key takes the one in flight.

It goes out as `Command::Expand` rather than acting in `App`, for the reason
`Command::Transcript` does: expanding reads the store and writes the
scrollback, and that type holds neither. Both loops have an arm, and the
mid-turn one is the point — it calls the same `expand` the command calls, so
the key and the command cannot draw different things.

`Ctrl+E` over `Ctrl+X` and `Ctrl+R`, which readline and every shell built on
it have spoken for. It may still collide with a terminal or a multiplexer,
and the answer is that it is rebindable — said on the table's own row rather
than in prose somewhere else.

The approval row in the same table gained `w`, which is the answer that
writes a rule down.
A fan-out reported `spawned child 188 success, steps 1; spawned child 189
success, steps 1` and the parent then said, in its own words, that the
sub-agents "returned no message of their own" — before redoing both
children's work itself. A child can be paid for and produce nothing anybody
ever sees, which is the most expensive shape a silence takes.

Nothing on the event stream carries a conclusion. `EventKind::Spawned`
announces the child and the ending events say how it ended; what it *said*
lives in `agent_events`, and io-harness's own fold reads the last row of kind
`said` — four lines, `pub(super)`. The same four are available over the
public `Store::agent_events`, so this reproduces a known reader rather than
inventing a second opinion about one or waiting for an upstream accessor.

A child that finished silently says so. Drawing nothing for it would make
"said nothing" and "still being read" the same picture, and only one of those
is worth spawning again — which is exactly the distinction the parent needed
and did not have.

Read at the turn boundary and above the panel's own guard, because the
conclusion is not only for the panel: it is the thing the parent was never
told, and a child that finishes while the panel is closed must still have its
last word when it opens. A working child is skipped, so a half-finished
answer is never drawn as a conclusion, and one already read is skipped too —
this is not a query per step per child.

An unreadable store leaves the field alone rather than writing `None` into
it: a store that would not answer is not a child that said nothing, and those
two must not look the same.
Three of this release's items turned out to be io-harness 0.86.0's work
rather than io-cli's, and what io-cli owes for each is a surface or a proof
rather than an implementation.

MCP server stderr is readable where it went. Every run loading a server
printed its banner onto io's own error channel — `semlith 0.8.0: serving 1
store` in the middle of a CI log — so third-party output and io's
diagnostics were one stream. 0.86.0 keeps it as a `ContextEvent` instead,
which fixed the pollution and left the text unreadable; a server that will
not start writes its reason there. `io mcp get` shows it, under the id that
wrote it — the rows carry `<id>: <text>` and attributing one server's
diagnostics to another would be worse than not showing them.

The shell grammar reaches the model, and io-cli holds no copy of it. The
table is `pub(crate)` upstream; a copy here would be a second opinion about
somebody else's rules, stale the first time one is added. What is asserted is
what this crate depends on — that the pinned harness still splices the list
into the description it hands a provider, and that the list is populated
rather than an empty placeholder that would satisfy a needle and tell a model
nothing.

A shell-made change can be put back. `/undo` reported "no file was put back"
for anything written with `echo >>`, `>` or `mv`, which are exactly the
tools a model reaches for — the journal had a hole the size of the shell.
0.86.0 journals the redirections and the two file-moving builtins; an
in-place editor is still outside it, which is stated rather than worked
around.

A provider's identifiers are gone before io-cli sees them, and io-cli redacts
nothing. The contract had this crate strip them at the renderer; 0.86.0 does
it at the source, so a second pass would be a second cleaner whose rules can
drift. Both halves are asserted — that the id is gone, and that no file under
`src/` has grown a redaction of its own.

And the one refusal that names its cure. A path inside io's own configuration
home is refused by the workspace-root check, before any policy and with no
layer to attribute it to, so it is the single refusal no posture, no rule and
no sandbox mode can lift — `--full-access` included. It is also the one an
operator meets while configuring io from inside io. It now says `use /config
to change it`, and only it does: naming `/config` on an ordinary denied write
would send somebody to a surface that cannot help.
… docs catch up

A field pass read `context: nothing assembled yet — the window is 24.0k` off
`/status` while every run of the same session emitted `context_ceiling` with
`max_tokens 1171456`. Both were true of different moments — before the first
request io-cli has no announced ceiling and falls back to io-harness's — and
nothing on either surface said which was which, so the two read as a
forty-eight-fold contradiction.

io-cli cannot supply the model's real window before a request: it holds no
catalogue, and `Provider::context_window` answers only from one a provider has
already fetched on that instance. What it can do is stop presenting a fallback
as the answer, in io-harness's own word for it — and stop saying so the moment
a run announces a real one, which keeps the note from becoming furniture.

The documentation catches up with the release reversing its own cut. The
`always` answer was cut mid-release and three shipped pages claimed it anyway;
`n7` was written to stop that. The cut was then reversed and the answer built,
which left `n7` forbidding the documentation of a real feature — so it is
replaced rather than weakened, by the gate the new premise needs: every page
that says io writes a `[[policy.layers]]` rule must also say the rule goes to
the USER scope and never a workspace file.

That gate took three aims to get right, and the two wrong ones are recorded in
it. "write" near "rule" matched a guide's index row. `[[policy.layers]]` near
"writes" matched a paragraph about adding an MCP server, where the thing
written is the server entry. It is keyed on `io-remembered` now — the layer
nothing but a `w` answer puts a rule into — and asserted per PAGE rather than
per paragraph, because the sentence naming the scope is legitimately a
paragraph away from the one naming the layer.

Verified by removing the workspace clause from `limits.md` and watching it go
red.
…writer

A fresh install could not decompose a turn at all and nothing said so. The caps
in [app.io-cli.containment] are the switch — Session::turn_contained_bounded_steered
is the only entry point that reaches io-harness's spawn loop — and the only way
to find that out was to type /contain on and be told.

The wizard now has a ninth screen, between the posture step and the confirmation,
offering the same four ceilings /contain on offers. Row 0 declines, like every
other confirmation in this product, and declining writes nothing at all: the file
that comes out is byte-identical to the one 0.40.0 wrote, so an operator who says
no is not carrying a section they never asked for.

The two doors could not share an implementation — the wizard renders a whole file
through settings::render, /contain on splices one value into a file somebody else
wrote — so the inline spelling moves into settings::containment_inline and both
are rendered, parsed back and compared in tests/contain.rs. A fifth cap added to
offered_containment later is picked up for free by the serializing door and not
by the formatting one, and that is the drift this asserts against.
…t read

Found by running the built binary against a fresh IO_CONFIG_HOME during this
release's own verification — two items after the same defect was corrected for
`io mcp list`. Bare `io resume` printed nothing at all when no run is parked,
which at a terminal is indistinguishable from a verb that hung, a store that
failed to open, or a binary that did not run. It is the door an operator reaches
for precisely when they do not know whether there is anything to carry on.

The sentence goes to stderr and is not suppressed under --json, so a script
reading rows still reads zero rows, which is the answer it asked for.

Driven through the built binary rather than resume_main: the store path comes
from settings::store_path reading the environment, and the claim is about what
the command prints on which stream.
@aakashpawar1999
aakashpawar1999 merged commit 1105245 into main Sep 12, 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