The configuration file has reached your terminal since 0.14.0: every section of
it bounds a session turn as it already bounded io exec, /status commits the
whole picture into the scrollback, and the ceilings in force are on the status
line beside what has been drawn against them.
[[hook]] and capability bundles are applied from 0.20.0, each with the
surface the omission was waiting on: /plugin for what a bundle brought and what
was dropped, and io-harness's own refusal sentence for a hook a project file may
not declare. [prices] is read from 0.22.0, which is the release that reads
the provider-call rows it prices. A contract carries a verification criterion
from 0.24.0, which was the last of these to be named here and was named with its
reason: it needed a surface of its own, and /gates is that surface. There is
still no [verify] section, because there is none in io-harness's schema to
apply — what a session carries comes from [app.io-cli.gates], which is io-cli's
own. See Verification gates. [run.commit_identity] is
read from 0.25.0: it has reached a turn's contract since 0.14.0 like every
other section, and until there was a commit to author it was a value nothing in
this interface had cause to look at. See Git.
Every [run] key now reaches both doors. run.templates was named here as
unapplied until 0.35.0, and the sentence was wrong about which half was missing:
the key was read, through its own accessor, but only by an interactive session —
so io exec ignored a key the configuration page documents. A configuration key
that works in the terminal and does nothing in CI is worse than one that works
nowhere, because nothing tells you. A headless goal written as /name is now
rendered from the template of that name; a goal that does not begin with / is a
prompt and is passed through untouched. Both doors render through one function
with one argument list, so a template cannot mean two things.
A price is never invented, and a missing one is never a zero. io-cli compiles no rates in and estimates nothing, so an install that has connected no provider reports tokens and no currency, and a total containing a model the table does not price is reported as a floor. See What it costs.
/import does not bring a permission boundary across, and never will. Another
tool spells a permission as a command line; io-harness matches a binary name. The
nearest honest translation is wider than what you granted, so io reports the
allowlist it found and writes no rule at all. Setting the boundary here is
Shift+Tab, /config, or the io-permissions skill — three surfaces where you
can see what you are granting. See Bringing your setup
across.
Git stops at your own checkout. The seven built-ins the agent has are fixed
argvs with no remote among them, so nothing here pushes, fetches or opens a pull
request, and 0.25.0 adds no surface that does. Nothing removes a worktree or
deletes a branch either — both throw away work, and both are yours to decide. And
io-cli starts no git process of its own: the branch on the status line is a read
of .git/HEAD, and /commit is a prompt. What lifts the tools when your posture
refuses them is a permission rule, not a code path that runs git behind the
policy. See Git.
Sixel is still absent, because encoding it means palette quantisation and another dependency, for terminals that either speak one of the two protocols already here or draw half blocks correctly. The Kitty path covers PNG rather than all four wire formats, because Kitty's own transfer format is PNG and the only base64 this program has is the one io-harness already computed — a screenshot is a PNG everywhere that takes one.
A text-only model plus an image is a failed run, and it fails at the wire. Whether a provider takes image input is asked before an attachment is accepted, but that is a question about the provider — with OpenRouter in front of four hundred models, the answer is yes while the particular model you have chosen may still be text-only. What you get then is the provider's own refusal, mid-run:
error: provider error (Request, HTTP 404): {"error":{"message":"No endpoints
found that support image input","code":404}}
The step and its tokens are already spent when it arrives. This also reaches you
without attaching anything, because enabling images gave the agent view_image
and the agent may decide to use it on a model that cannot see — and io-cli cannot
take a tool out of io-harness's own workspace tool set. Checking the model rather
than the provider would mean reading the live catalogue on every attach, and it
would still not close the door the agent opens. If you work with images, choose
a model that accepts them. From 0.37.0 you can also close that particular door
for a turn with /context withhold view_image, which refuses the call rather
than removing the tool — see below.
The twelve document tools cannot be taken out of that tool set either, and
six of them write. A model that reaches for docx_write in a session where you
never meant a document to be written is stopped by the write gate rather than by
the absence of the tool — which is what the gate is for, and why the writers are
named one by one in Documents rather than counted.
Since 0.37.0 you can stop it earlier, by name. /context withhold docx_write
refuses that tool before anything is started, and it stays refused until you say
otherwise — /context withhold is a posture, not a one-shot. /context allow docx_write puts that one back; bare /context allow clears the whole mask,
so name the tool unless dropping every one of them is what you meant. Both
sentences above stay true: the tool is not taken out of the
set, the catalogue the model is offered is byte for byte the one it would have
been offered anyway, and the definition still costs its tokens on every request.
What a mask changes is what may be called.
A mask does not make a turn cheaper, and it is worth saying so plainly because
it is the obvious thing to assume. io-harness sends the identical catalogue
either way on purpose — the tool array sits ahead of the provider's cache
breakpoint, so dropping a definition would save its tokens once and pay a cache
write on every later turn of the run. Withholding in fact makes a request
marginally larger: one sentence naming the withheld tools, placed where it costs
no cache entry. Use it to bound what the agent may do, and use /context and
/plugin to see what your extensions cost — those are two different questions
and only the second one is about money.
That is a statement about the mask, and io-harness 0.81.0 added a different lever that is about the catalogue's size: tiered tool exposure, which offers the core tools and names the rest in one line until the model asks for a family. It makes exactly the trade described above — a smaller catalogue against a rewritten cache prefix — and the harness measures it as worth 23% of the description tokens. io does not turn it on, and this page will say so until io does: a lever nothing here reaches is not a feature this product has.
A mask does not reach a spawned child. With [app.io-cli.containment]
configured, a contained turn's children get contracts io-harness builds fresh,
and it carries no mask into them deliberately — the mask is a request about your
turn, and a child's work is not your turn. So a tool you withheld can still be
called inside the fleet. See The fleet.
An image the agent was given rather than asked for is not shown. A picture
returned by an MCP tool, and a browser screenshot, both become images inside
io-harness. Until 0.81.0 that happened through private plumbing with no event of
any kind, so nothing reached this program at all; since 0.81.0 there is an event
carrying the media type, the size, the digest and where the picture came from.
What is missing now is the drawing rather than the fact — io does not render a
picture it was not handed itself, and src/picture.rs draws only what you
attached with /attach.
A skill is listed by name and never pasted. A template is expanded by io-cli
into prompt text, so nothing but this program is involved; a skill is read by the
model, through a tool, under the run's own policy. Choosing one from the
palette puts use the <name> skill: in your prompt and stops there. io-cli
parses no skill file: the five it ships from 0.19.0 are bodies it carries and
writes to disk, and after that they are read the same way yours are — by the
model, through the tool, under the policy.
io exec runs one goal and stops, and a run that pauses is still not answered
by a machine. An agent that asks a question about what you meant, or proposes a
plan, ends the run at exit 4 with the pause persisted in the store. That is
io-harness's behaviour and it is the right one — a machine answering a question
about intent on your behalf sends the agent down a path nobody chose — so io exec parks the run and says which pause it is parked on. Answering it is a
person's job, and from 0.23.0 there is a door for that: io resume from a
script, /resume from a session. Approvals are the one pause that cannot happen
in a headless run, because they are declined rather than deferred.
There are no --max-steps, --timeout or --max-tokens flags either: [run]
in the configuration file expresses all three, and a CI job's limits belong with
the project rather than in every invocation.
A rewind does not check whether you edited a file yourself since the turn. It
puts each file back to the state before that turn first wrote it, and it does not
compare that against what is on disk now — so a hand edit made afterwards is
overwritten. io cannot detect this, because the snapshot it restores from is not
readable from outside io-harness; what it does instead is tell you, in the prompt,
before the second keystroke. This is what git checkout -- <path> does too, and
it is said here rather than left to be discovered. Making it preventable is a
change to io-harness, not to this interface.
A rewind undoes one turn, the last one. /resume lists every session the
walk found — the twenty-row cap is gone, because it existed only to keep a list
short that nobody could filter. One bound is left, on how many runs the walk will
look at, and the list still says so when it has cut rather than quietly showing
you a subset.
Two more things are absent for reasons worth stating rather than hiding. A diff
cannot be expanded beyond
the context the harness stored — three lines either side, which is what
diff -u has always carried; more than that is not in the trace, and reading it
off disk would be reading a version of the file that no longer exists. And
there is no split view: this renderer commits into the terminal's own
scrollback at its real width, a two-column comparison doubles the horizontal
budget for every line, and word-level emphasis inside a unified diff already
answers the question split view answers.
One ceiling worth knowing about: a hunk is a fragment of a file, and each of its lines is highlighted from a clean parse. A block comment or a multi-line string that was opened above the hunk is not known here, so those lines read as code.
From 0.41.0 a refusal can become a question. Which refusals, and which never, is the whole of what makes that safe — so it is written down here rather than left to be discovered.
This release widens nothing a configuration file could not already express. It changes when io asks, never what io permits. Every verdict is still io-harness's; io-cli holds no policy engine and evaluates nothing.
| Where the refusal came from | What happens | Can you lift it? |
|---|---|---|
policy.defaults — the tier that applies when no rule matched |
It asks. The overlay opens: y allows that call, a allows it for the rest of the session, w allows it and writes a rule into your own configuration, n denies it |
Yes, by answering |
A [[policy.layers]] rule you wrote |
It refuses. Silently, always. Nothing is drawn and nothing is asked | Only by editing the rule |
| A path outside the workspace root | It refuses, and no setting anywhere lifts it | No |
The middle row is the floor. A default is io-harness answering because nothing
said otherwise; a layer rule is a decision you wrote down, and io asking whether
to ignore it would make the writing down worthless. So .env, *.pem, id_rsa
and everything else the built-in secrets layer denies stay denied — under every
posture, under escalation, and under --full-access.
The last row is not a policy verdict at all. The tool layer holds one workspace
root and refuses an escape from it before any policy is consulted, with no layer
to attribute it to. That is why the agent cannot read or write ~/.io-cli/io.toml
through the file tools at any posture, full access included — use /config to
change it, which is the surface that exists for exactly this.
Turn the asking off with [app.io-cli] escalate = false, which restores 0.40.0's
behaviour exactly.
A w answer writes to your user scope and never a workspace file. 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 why io-harness refuses
a widening from a workspace file in the first place. If the user scope is not
writable you are told, and the allowance still holds for the session.
/policy revoke un-writes what io wrote, and nothing else. Every rule it
offers sits in the io-remembered layer, which only a w answer puts a rule into.
A rule in any other layer is yours — written deliberately, in an editor, very
possibly to deny something — and io names the layer instead of offering to delete
it. A permission surface that can remove a permission boundary is not one worth
having.
A provider's error body is redacted before io ever sees it. io-harness 0.86.0
replaces the value of every JSON field whose name ends _id with [redacted] on
the way into the error, so an identifier in a 400 no longer reaches the transcript.
io-cli adds no redaction of its own — a second pass over an already-cleaned string
would be a second opinion about it. What that pass cannot reach, stated rather than
implied: a value split across two fields, a body that is not field-shaped, and an
identifier sitting in ordinary prose.
The shell tool's rules are stated to the model rather than met one at a time. The complete set of constructs it refuses is carried in the tool's own description, so a model is told before it tries. io-cli holds no copy of that list: it renders the tool catalogue it is handed.
A shell-made change can be put back, except an in-place edit. See
the store for which redirections journal and why
sed -i does not.