Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
103 changes: 102 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,106 @@ All notable changes to this project are documented here. The format follows

## [Unreleased]

## [0.34.1] - 2026-09-01

A patch about honesty rather than capability. This product machine-enforces its own
marketing — a test fails if a HTTP client, a TLS stack, a database or a sandbox appears
in the crate, and another fails if a scripted session writes an alternate-screen or
mouse-capture sequence — and four published claims got past all of it, because no test
decided any of them. One of the four was a real defect that a script hits silently and
never investigates.

### Changed

**A usage error exits `1`. It used to exit `2`, and `2` means something else.**
`Cli::parse()` exits with clap's usage code, which is `2` — the number
`docs/CONTRACT.md` publishes as `REFUSED`, "denied, refused, or the plan was
rejected", since 0.5.0. So a mistyped flag and a run the policy stopped produced the
same status at the one surface this product offers to be branched on, and a CI job
asking "did the boundary stop this?" was told yes by every typo. io-cli's own
argument refusals already exited `1`, so the collision was not even consistent with
the rest of the binary. A parse failure is now `FAILED` (`1`), which is what the
contract always said it was; `--help` and `--version` are not failures and still
exit `0`. clap 4 publishes no builder knob for this, so the exit is taken from
`Cli::try_parse` and split on clap's own `use_stderr`.

**`install.ps1` no longer writes the user PATH.** It prints the line to add, which is
what `install.sh` has always done and what the README has always said *both* scripts
do. A user PATH write is not a shell profile — the README's original wording was
literally true and its meaning was not — but it is still a change to the machine that
outlives the install, made by a script piped into a shell, in a place nobody has a
reason to look. Nothing is un-written: an install that already added
`%LOCALAPPDATA%\io\bin` keeps that entry, because the script cannot tell its own old
edit from one made on purpose.

### Fixed

**Exit `4` no longer promises an invocation for the pause that does not have one.**
The contract said exit `4` names the `question_id`, `plan_id` or `attempt_id` that
`io resume` needs. That is true of three of the four pauses. An approval is answered
by the person the run asked, at the terminal it asked from, and io-harness publishes
no resume entry point that takes one — the binary has always said so and only the
documentation over-claimed, so a script written from the page went looking for a
command that does not exist, in the one case where somebody is already waiting.

**The headless refusal claim carves out the provider's own endpoint.** "Every
approval in a headless run is declined, and the refusal is fed back to the agent as
an observation it can adapt to" is true of every approval a tool call raises, and
false of one. The provider's endpoint is authorized once, before the run's first
step, so an `ask` verdict on that host ends the run with no turn to tell about it and
nothing to adapt around. Reachable only through an explicit `act = "net", effect =
"ask"` rule matching that host — the harness contributes an allow for it from a layer
of its own, so an ordinary `net`-denying posture still reaches the model — and
nothing warns first, because the notice an asking posture earns reads `write` and
`exec` and never `net`.

**Drift tests now decide all four claims.** The exit-code table in `docs/CONTRACT.md`
is read as data and held against the constants row by row, with a pairwise
distinctness check, so a later release cannot trade one collision for a neighbour
without a test naming which row moved. The two corrected sentences are bound to the
functions they describe rather than to themselves. The installer and the README are
bound to each other, so a future edit that re-adds the PATH write fails rather than
quietly making the README a lie. The exit codes are asserted by driving the built
binary, because the decision lives in `src/main.rs` and nothing under `tests/` links
it.

### Known limitations

**A boundary refusal on the provider endpoint exits `1`, not `2`.** The case
documented above ends the run through io-harness's typed `Error::Refused`, and
`src/exec.rs` flattens that to a message before an exit code is chosen — so a real
policy refusal is reported as a failure, which is the same class of defect as the one
this release fixes, in the other direction. Correcting it means re-typing the error
seam and auditing which of the harness's other errors are refusals; that is more than
a patch, it is named in the documentation rather than left to be discovered, and it
is 0.35.0's.

### Dependencies

**Unchanged.** The io-harness pin stays at 0.73.0 where 0.34.0 put it, and the direct
dependency set is the same ten names. A patch that moved a pin across a minor with declared
breaks would not be a patch.

### Upgrading

**Read this if a script branches on any `io` command's exit status**, not only `io exec`.
The change is at the top-level parser, so `io resume`, `io mcp`, `io plugin`, `io config`
and `io skill` are affected the same way. `2` no longer means "refused or a usage error";
it means refused. A usage error is `1`. A script that tested for `2` to detect a mistyped
flag now sees `1`, and one that tested for `2` to detect a policy refusal now gets a `2`
that only ever means that. Nothing else moved: `0`, `3`, `4`, `5` and `6` mean what they
have meant since 0.5.0 and 0.24.0.

**On Windows, a fresh install no longer edits your PATH.** The installer prints the
line instead. An existing install keeps the PATH entry it already has.

**And the printed line reaches less than the write did — say so rather than let it be
found.** A user PATH write was visible to cmd.exe, Explorer and every GUI-launched
program; `$env:Path += …` in a PowerShell profile is PowerShell's alone. The installer
now prints that distinction and points at System > Environment Variables for the rest.
`install.sh` has always had the same shell-specific limit, so the two are symmetric — but
on Windows this is a reduction in reach and not only a removal of a write.

## [0.34.0] - 2026-08-31

0.32.0 made io-harness's `__` a wire detail: a bundle's contribution is drawn with
Expand Down Expand Up @@ -3075,7 +3175,8 @@ client, tool, sandbox, policy engine or session store of its own.
- There is no crates.io publish and `cargo install` is not an install path.
- No test in this release asserts on wall-clock time.

[Unreleased]: https://github.com/initorigin/io-cli/compare/v0.34.0...HEAD
[Unreleased]: https://github.com/initorigin/io-cli/compare/v0.34.1...HEAD
[0.34.1]: https://github.com/initorigin/io-cli/compare/v0.34.0...v0.34.1
[0.34.0]: https://github.com/initorigin/io-cli/compare/v0.33.0...v0.34.0
[0.33.0]: https://github.com/initorigin/io-cli/compare/v0.32.0...v0.33.0
[0.32.0]: https://github.com/initorigin/io-cli/compare/v0.31.0...v0.32.0
Expand Down
2 changes: 1 addition & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "io-cli"
version = "0.34.0"
version = "0.34.1"
edition = "2021"
# io-harness declares 1.95, set by `libsqlite3-sys` calling the std `cfg_select!`
# macro from its build script. Nothing here can be lower than what the harness
Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -98,8 +98,8 @@ irm https://raw.githubusercontent.com/initorigin/io-cli/main/install.ps1 | iex
Both scripts pick the right build for your machine, **verify it against the
published `SHA256SUMS` before unpacking it**, and install into a directory you
own — `~/.local/bin`, or `%LOCALAPPDATA%\io\bin` on Windows. Neither needs
administrator rights and neither edits your shell profile: if the directory is
not on your `PATH`, the script prints the line to add.
administrator rights and neither edits your shell profile or your `PATH`: if
the directory is not on your `PATH`, the script prints the line to add.

Re-running the script is how you update. There is no auto-update and no version
check: a terminal tool that contacts a server you did not ask it to contact is
Expand Down
20 changes: 17 additions & 3 deletions docs/CONTRACT.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,14 +48,28 @@ to the existing `3` would have moved exactly the runs `6` was invented for.
| Code | Name | Means |
| --- | --- | --- |
| `0` | OK | The run finished |
| `1` | FAILED | The run failed |
| `1` | FAILED | The run failed, or the command line was not understood |
| `2` | REFUSED | Denied, refused, or the plan was rejected |
| `3` | CEILING | A step, time, cost or budget ceiling was reached, and nothing judged the work |
| `4` | PAUSED | The run is waiting on a question, a plan, an approval or an interrupted call — resumable with `io resume` |
| `4` | PAUSED | The run is waiting on a question, a plan, an approval or an interrupted call — resumable with `io resume`, except an approval |
| `5` | UNFINISHED | The run ended in a state this table does not name |
| `6` | UNVERIFIED | The work was judged by a verification gate and did not hold up |

Exit `4` names the `question_id`, `plan_id` or `attempt_id` that `io resume` needs.
Exit `4` names the `question_id`, `plan_id` or `attempt_id` that `io resume` needs — for three of
the four pauses. **An approval is the fourth and it names no invocation**, because there is none:
io-harness publishes no resume entry point that takes an approval, and one is answered by the
person the run asked, at the terminal it asked from. That pause names its `request_id` and says
that instead, rather than printing a command a script would find does not exist. **`io` does not
produce that pause today at all** — it declines every approval rather than deferring one, in a
session and headless alike — so the carve-out is what would be printed if it ever did, and is
written down because the code that would print it exists and is reached by nothing.

**One boundary refusal does not reach `2` today, and this is a known defect rather than the
contract.** A headless run whose provider endpoint lands in the `ask` tier is refused by io-harness
before the run's first step; that typed refusal is flattened to a message before an exit code is
chosen, so it exits `1`. It is reachable only through an explicit `act = "net", effect = "ask"`
rule matching the provider host — see [the headless guide](guide/headless.md), which describes it
where an operator meets it. Every other refusal exits `2`.

## Configuration

Expand Down
22 changes: 17 additions & 5 deletions docs/guide/headless.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,10 +25,22 @@ permits the agent to try.

**`--policy ask-writes` is refused.** Nothing in a headless run can answer an
approval, so honouring it would turn *ask before writes* into *deny writes*
without saying so. Every approval in a headless run is declined, and the
without saying so. Every approval a *tool call* raises is declined, and the
refusal is fed back to the agent as an observation it can adapt to — which is
what it already does with a policy refusal.

**The provider's own endpoint is the exception, and it is not adaptable.** That
host is authorized once, before the run's first step, so a policy that puts it in
the `ask` tier refuses the run there — there is no turn yet to tell about it, and
nothing to adapt. Only an explicit `act = "net", effect = "ask"` rule matching the
provider host reaches this: the harness contributes an allow for that endpoint
from a layer of its own, so an ordinary `net`-denying posture still gets to the
model, and `io setup` writes no such rule. Worth knowing because nothing warns
first — the notice above reads `write` and `exec` and never `net`. **A run refused
this way exits `1` and not the `2` the table below gives for a boundary**: the
typed refusal is flattened to a message before an exit code is chosen. That is a
known defect rather than the contract.

### Exit status

| Code | Means |
Expand Down Expand Up @@ -67,10 +79,10 @@ alone now has a sixth answer to handle, which is the point — before this relea
there was no status a gated run could return that said the work was not good
enough, because there were no gates.

**Exit `4` names the pause from 0.23.0, and the invocation that answers it.**
The closing line used to name the run id and nothing else, which addressed
none of the four pauses; it now names the question, plan or call the run stopped
on and the `io resume` that decides it. That release renumbered nothing and added
**Exit `4` names the pause from 0.23.0, and — for three of the four — the
invocation that answers it.** The closing line used to name the run id and
nothing else, which addressed none of the four pauses; it now names the question,
plan or call the run stopped on and the `io resume` that decides it. That release renumbered nothing and added
nothing: `4` had been given to a pause that could not yet be answered for exactly
that release.

Expand Down
40 changes: 29 additions & 11 deletions install.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,9 @@
Downloads the artifact for this machine and the SHA256SUMS beside it,
VERIFIES THE ARTIFACT BEFORE EXPANDING IT, and installs into
%LOCALAPPDATA%\io\bin — a directory the current user owns. No administrator
rights, nothing written outside the user's own profile, and nothing left
behind if anything fails.
rights, nothing written outside that directory and a temporary one it removes
on the way out — not your PATH either, which is printed for you to set rather
than set for you — and nothing left behind if anything fails.

The checksum defends against a truncated download and a tampered asset. It
does not defend against a compromised repository; piping a script from the
Expand Down Expand Up @@ -140,21 +141,38 @@ try {
Copy-Item -Path $binary -Destination $installed -Force
Write-Host "installed $installed"

# The USER PATH, never the machine PATH: the machine one needs administrator
# rights and would change the environment for everybody on the box.
$userPath = [Environment]::GetEnvironmentVariable('Path', 'User')
# The PATH line is printed rather than written. A user PATH write is not a
# shell profile — it goes to the user's own environment rather than a file
# they keep — but it is still a change to the machine that the operator did
# not ask for, made by a script they piped into a shell, and it outlives the
# install in a place they have no reason to look. The line below is one they
# can read before they run it. Nothing is un-written either, so an install
# from before this changed keeps whatever user PATH entry it added — this
# script cannot tell its own old edit from one the operator made on purpose,
# so it takes neither away.
#
# What is read is the PROCESS PATH, and that is not interchangeable with the
# user PATH in the registry now that nothing is written. $env:Path is what
# the advice below actually changes, and it is what the machine, user and
# session entries all resolve into — so an entry made any of those ways is
# seen here on the next run. Reading the registry value instead would be
# reading the one place the printed line never reaches: an operator who did
# exactly as they were told would be told to do it again on every update,
# forever, and the other branch would become unreachable for everybody who
# complied. install.sh reads $PATH for the same reason.
$entries = @()
if ($userPath) { $entries = $userPath -split ';' | Where-Object { $_ } }
if ($env:Path) { $entries = $env:Path -split ';' | Where-Object { $_ } }
if ($entries -notcontains $InstallDir) {
[Environment]::SetEnvironmentVariable('Path', (($entries + $InstallDir) -join ';'), 'User')
Write-Host ''
Write-Host "$InstallDir has been added to your user PATH."
Write-Host 'Open a NEW terminal and run: io'
Write-Host "$InstallDir is not on your PATH. Add this to your PowerShell profile:"
Write-Host ''
Write-Host 'If you would rather set it yourself, this is the line:'
Write-Host " `$env:Path += ';$InstallDir'"
Write-Host ''
Write-Host 'then open a new terminal and run: io'
Write-Host 'That line reaches PowerShell. For cmd.exe, Explorer and GUI'
Write-Host 'programs, add it through System > Environment Variables.'
} else {
Write-Host "$InstallDir is on your user PATH; run: io"
Write-Host "$InstallDir is on your PATH; run: io"
}

# The last line of the narration is the binary's own, not this script's: the
Expand Down
9 changes: 8 additions & 1 deletion src/exec.rs
Original file line number Diff line number Diff line change
Expand Up @@ -392,7 +392,14 @@ pub fn asks_nobody_can_answer(policy: &Policy) -> Option<String> {
}

/// The extra line a paused run gets, naming the handle that addresses the pause
/// and the invocation that acts on it.
/// and — for three of the four — the invocation that acts on it.
///
/// The fourth is an approval, which no `io resume` entry point takes, so that arm
/// names the request and says so rather than offering a command that does not
/// exist. Written out here as well as in the arm because the unqualified version
/// of this sentence is what `docs/CONTRACT.md` and the headless guide both
/// carried until 0.34.1, and a summary that over-claims is how it got into two
/// pages in the first place.
///
/// **The run id is not that handle, and through 0.22.0 this line printed only the
/// run id.** Every resume entry point in io-harness takes a second number — the
Expand Down
26 changes: 25 additions & 1 deletion src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,31 @@ fn main() -> ExitCode {
}

async fn run(report: &mut Vec<String>) -> Result<u8, String> {
let cli = Cli::parse();
// **A parse failure is `FAILED`, not `REFUSED`, and clap's default was the
// second one.** `Cli::parse()` exits with clap's usage code, which is 2 —
// the same 2 `exec::REFUSED` has meant since 0.5.0. `docs/CONTRACT.md`
// publishes those codes as a contract a CI job branches on, so the default
// told every such job that a mistyped flag was a boundary refusal, at the
// one surface this product offers to be scripted against. io-cli's own
// argument refusals — `--policy ask-writes` and the rest — already come back
// as `Err(String)` and exit 1, so the collision was not even consistent with
// the crate's own behaviour.
//
// Taken here rather than on the builder because clap 4 publishes no knob for
// it: there is no `error_exit_code` anywhere in `clap_builder` 4.6.6. The
// split is clap's own `use_stderr` — `--help` and `--version` are writes to
// stdout that are not failures and keep their 0, and everything clap sends
// to stderr is a usage error, which is what `FAILED` means.
let cli = Cli::try_parse().unwrap_or_else(|error| {
// Swallowing the print error is clap's own behaviour in `Error::exit`:
// a broken pipe on the way out is not worth a second failure.
let _ = error.print();
std::process::exit(i32::from(if error.use_stderr() {
io_cli::exec::FAILED
} else {
io_cli::exec::OK
}));
});
let root = match cli.dir {
Some(dir) => dir,
None => std::env::current_dir().map_err(|error| error.to_string())?,
Expand Down
Loading