Repository navigation
io-cli 0.41.0 - #114
Merged
Merged
io-cli 0.41.0#114
Conversation
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.
io-cli 0.41.0
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 = falseto 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:
yallows the call,aallows it for the session through the existing in-memoryrememberedlayer,wallows it and writes one[[policy.layers]]rule into the user-scope file, showing the exact text before it lands.ndenies./policylists every rule in force with the file that declares it and marks the ones io wrote;/policy revokeremoves 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-accessruns a session unconfined in one word — the only thing that reaches abind()the sandbox denies below policy. It is deliberately not in theShift+Tabcycle, and it is never written to a file.The field report
Gates:
config set … -- wordsstores a list and a quoted string is refused;gates.retriesis honoured by the headless driver; a failed gate quotes its output and exit code from io-harness 0.86.0's newgate_output; a conversationally-answered run says the gate was not evaluated rather than leaving a pass indistinguishable from a no-run.-Con a path that names nothing is an error.config geton a missing key exits 1. Bareio resumelists, and says so when nothing is parked.io mcp getinspects.provider.kind,provider.modelandprovider.base_urlare readable.io exec --jsonends with acostline.--sandboxis accepted on either side of the subcommand. Every subcommand's--helpnames its verbs. A slash command typed in full runs on the first Enter, the palette matches/contain ontyped in one go, and its⋯ N morecounter counts what is actually hidden./statussays 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+Eputs the running step's full text into the scrollback.io setupnow 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 -- --checkandcargo clippy --all-targets -- -D warningsclean. 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 resumesilence above was found.