io-harness 0.9.0

A Rust agent harness: run an AI agent from a typed task contract to a verified result. Provider-agnostic (OpenRouter, Anthropic, OpenAI), multi-file edits with grep/find over a workspace, budgets, retry, full trace, resumable runs, execution-based verification, a layered permission policy with a human-approval gate, contained sub-agent composition, an OS-native/OS-neutral execution sandbox (macOS sandbox-exec, Linux namespaces, Windows Job Objects, portable floor) that isolates model-produced code per run, durable checkpoint/resume for unattended runs, an MCP client (stdio and streamable HTTP) whose tools reach the agent beside the built-ins, and a deny-by-default network egress policy. Embeddable in-process.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
# IO Harness

> A Rust agent harness that runs AI agents from a typed task contract to a checked result.

The shared engine every initorigin app (io-cli, io-studio) and io-eval build on.

**Type:** Rust library crate
**Stack:** Rust · cargo · tokio · rusqlite · rmcp · own HTTP+SSE provider client
**License:** Apache-2.0
**Status:** Pre-release. v0.1 shipped the single-agent file-edit loop (filesystem tool, OpenRouter provider, deterministic verify, rusqlite audit). v0.2 adds step/time/cost budgets, retry with escalation, a full trace, resumable runs, and execution-based verification that compiles the produced file so a substring stub cannot pass. v0.3 adds repository-wide work — `grep` and `find` tools over a workspace and multi-file edits in one run — and two more providers (Anthropic and OpenAI) behind the same provider-agnostic surface, selected at run construction. v0.4 adds the permission boundary: a layered policy over reads, writes, and command execution, enforced in the tool layer rather than the prompt, plus a human-approval gate that can approve, rewrite, remember, deny, or defer a decision until after the process has exited. v0.5 adds agent composition with containment: a parent decomposes a task and spawns contained sub-agents (100+ concurrently) over one shared workspace and trace, composing their results back; a child inherits its parent's policy and can only narrow it, and the whole tree runs under one aggregate spend ceiling no spawned task can raise. v0.6 adds the execution sandbox: every command the verification gate runs — the `rustc` compile and the test binary it has run since v0.2 — executes inside an ephemeral, per-run sandbox (isolated workdir, resource caps that kill, network denied by default, guaranteed teardown), so model-produced code no longer runs on the host directly. The sandbox is OS-native and OS-neutral — one trait, a native backend per platform (macOS `sandbox-exec`, Linux namespaces, Windows Job Objects) over a portable floor that runs everywhere. v0.7 makes a run durable and unattended: every step is checkpointed transactionally, and a crash or full restart resumes the whole agent tree from its own last committed step without re-running work, double-charging the budget, or re-applying an edit. v0.8 makes the harness extensible and its network reach governed: it is an MCP client (stdio and streamable HTTP) whose servers' tools reach the agent beside the built-ins under namespaced names, and outbound connections become a fourth permission act — deny-by-default, layered, contained downward, and traced. v0.9 closes the tool layer with the in-process half of extensibility: implement the public `Tool` trait for an action your own binary already performs, register it on the task contract, and the model is offered it beside the built-ins — governed by the same policy, refused as an observation rather than a failed run, size-capped, traced, and inherited by a v0.5 child; plus skills, a directory of markdown instruction files whose names and descriptions reach the system prompt and whose bodies the agent loads on demand through a built-in `read_skill` tool.

## Capabilities

- Task contract — goal, constraints, expected output, success criteria
- Context construction — feed the model only relevant, current, trusted info
- Tool layer — narrow, typed actions the agent invokes ✅ **v0.9** completes it: the public `Tool` trait, in-process and policy-governed
- Orchestration loop — observe, reason, act, check, stop
- State and memory — progress, intermediate results, decisions (rusqlite)
- Verification layer — tests, schemas, read-backs confirm the task is done
- Permissions and guardrails — what the agent may access, change, send, spend ✅ **v0.8** completes *send*: `Act::Net`, deny-by-default egress
- Recovery and retry — retries, fallbacks, replanning, escalation
- Stop conditions and budgets — cap steps, time, cost, retries, risky actions
- Observability and tracing — record prompts, decisions, tool calls, cost
- Human approval layer — review before sensitive or irreversible actions
- Providers — OpenRouter first, then Anthropic and OpenAI (own HTTP+SSE client)
- Agent composition — spawn and nest many agents (100+) with shared context
- Long-running autonomous tasks — 24h+ with no user input
- Ephemeral local code-exec sandboxes — write, run, capture, destroy ✅ **v0.6** (OS-native + OS-neutral: macOS `sandbox-exec`, Linux namespaces, portable floor everywhere — Windows is the floor only, its Job Object is not implemented yet)
- Built-in tools — filesystem, git, grep, find
- Office and document tools — Word/Excel/PowerPoint/PDF create/edit/delete, PDF watermark, PDF form fill, OCR, barcode/QR read and generate
- Media — image and video passthrough when the model supports it
- Extensibility — MCP (rmcp) ✅ **v0.8** (client; stdio + streamable HTTP), in-process `Tool` implementations and skills ✅ **v0.9**

See [docs/CAPABILITIES.md](docs/CAPABILITIES.md) for detail and
[docs/CONTRACT.md](docs/CONTRACT.md) for the public contract.

## Usage (v0.4)

Hand the harness a task contract; it runs the loop, verifies the result, records
every step to rusqlite, and stops on success or a budget. A single-file task uses
the filesystem tool; a workspace task lets the agent `grep`/`find` across a
repository and edit several files. Pick any of three providers at construction.
Everything else in **Capabilities** is roadmap.

### 1. Add the crate

```toml
[dependencies]
io-harness = "0.3"
tokio = { version = "1", features = ["rt-multi-thread", "macros"] }
```

Upgrading from 0.2: `TaskContract::new`, `run`, `resume`, and the single-file
loop are unchanged, so existing callers keep compiling. New in 0.3:
`TaskContract::workspace`, the `grep`/`find`/`read_file` tools, the
`EachCompilesRust` / `WorkspaceTestPasses` verifications, and the `Anthropic` /
`OpenAi` providers. If you implement `Provider` yourself, note it gained a
defaulted `name()` method (override it to label your provider in the trace — no
change required to keep compiling). A 0.2 rusqlite database is migrated in place
on open (a `provider` column is added; a 0.2 binary still reads it).

### 2. Choose a provider

Each provider reads its own key + model slug from the environment; credentials
are never logged, and no default model is guessed. Selecting a provider is just
constructing a different one — the task contract does not change.

```bash
# OpenRouter
export OPENROUTER_API_KEY=sk-or-...
export OPENROUTER_MODEL=anthropic/claude-sonnet-4
# Anthropic
export ANTHROPIC_API_KEY=sk-ant-...
export ANTHROPIC_MODEL=claude-sonnet-4
# OpenAI
export OPENAI_API_KEY=sk-...
export OPENAI_MODEL=gpt-4o
```

```rust
use io_harness::{Anthropic, OpenAi, OpenRouter};

let provider = OpenRouter::from_env()?;   // or:
let provider = Anthropic::from_env()?;    // or:
let provider = OpenAi::from_env()?;
// ...then hand `&provider` to `run` — nothing else changes.
```

### 3. Run one file-edit task, bounded and verified by execution

```rust
use std::time::Duration;
use io_harness::{run, OpenRouter, Store, TaskContract, Verification};

#[tokio::main]
async fn main() -> io_harness::Result<()> {
    let contract = TaskContract::new(
        "add a `hello` function that returns 42",
        "src/hello.rs",
        // Execution-based: the file must compile AND pass this test. A stub
        // that only contains the right substring fails the gate. Since v0.8.1
        // the file is compiled as its own crate and this test compiles against
        // it, so the file cannot shadow `assert_eq!` or delete the test to pass.
        Verification::RustTestPasses {
            test_src: "#[test] fn t() { assert_eq!(hello(), 42); }".into(),
        },
    )
    .with_max_steps(8)                              // step budget
    .with_time_budget(Duration::from_secs(120))     // time budget
    .with_token_budget(200_000)                     // cost budget, in tokens
    .with_max_retries(2);                           // retry transient failures

    let provider = OpenRouter::from_env()?;
    let store = Store::open("runs.db")?;

    let result = run(&contract, &provider, &store).await?;
    // Success | StepCapReached | TimeBudgetExceeded | CostBudgetExceeded
    println!("{:?}", result.outcome);
    Ok(())
}
```

### 4. Run a repository task: grep, find, and multi-file edits

Give the agent a workspace root instead of one file. It can `grep` file contents
(regex or substring), `find` files by name/path glob, `read_file` to inspect
what it found, and `write_file` to edit several files — all confined to the root
(a `..` or absolute path that escapes is refused). Verification spans the set:
`WorkspaceTestPasses` compiles the edited files together with a test, so the run
only succeeds when the whole set is correct.

```rust
use io_harness::{run, OpenRouter, Store, TaskContract, Verification};

let contract = TaskContract::workspace(
    "Make a() + b() equal 42 by editing the two source files.",
    "my-repo",                                  // workspace root
    Verification::WorkspaceTestPasses {
        files: vec!["src/a.rs".into(), "src/b.rs".into()],
        test_src: "#[test] fn t() { assert_eq!(a() + b(), 42); }".into(),
    },
);

let result = run(&contract, &OpenRouter::from_env()?, &Store::memory()?).await?;
println!("{:?}", result.outcome);
```

The grep/find tools skip `.git`, `target`, and `node_modules`. Use
`Verification::EachCompilesRust(files)` when each edited file only needs to
compile on its own.

### Execution-based verification

Content checks (`FileContains`, `FileEquals`) confirm the file *says* the right
thing but not that it *works* — the 0.1 live run passed `FileContains("fn hello")`
by writing the literal string `fn hello`, which does not compile. v0.2 adds gates
that run the artifact:

- `Verification::CompilesRust` — passes only if the file compiles (`rustc --crate-type lib`).
- `Verification::RustTestPasses { test_src }` — compiles `test_src` against the file and passes only if the test binary passes.

Compilation runs locally in a throwaway temp dir (removed afterwards) and touches
no network.

**What a passing gate proves.** That the stated criterion was satisfied — not
that the artifact is correct. A gate asserting `hello() == 42` is silent about
everything else the file does. Execution gates are stronger than content gates
because a stub cannot compile and run, but stronger is not sufficient.

**The subject cannot defeat its own gate (since v0.8.1).** Until v0.8.1 the file
under verification and your `test_src` were compiled as one crate, so the file
could change how your criterion resolved. A file defining `#[macro_export]
macro_rules! assert` made `assert!(false, "this gate can never pass")` report a
pass; a file opening with `#![cfg(any())]` deleted the criterion entirely and the
empty test binary exited 0. An agent found the first of these unprompted during
io-cli 0.1.0's live runs. Your criterion now sits in a module of the file's crate
that re-imports the prelude macros explicitly, so a file defining its own `assert`
makes the name ambiguous instead of capturing it, and the gate fails to compile
rather than passing; a probe compiled alongside the file catches one that deleted
its own contents. Nothing changed in what you write: `test_src` still calls the
file's items unqualified, a macro the file legitimately exports still reaches it,
and your criterion still reaches the file's **private** items — an implementation
does not have to be `pub` to pass.

The compile-only gates had the same hole by a different route, needing no
criterion at all: `#![cfg(any())]` strips a file's items *before* rustc
type-checks them, so `pub fn hello() -> u32 { "not a u32" }` compiled clean and
passed. `CompilesRust` and `EachCompilesRust` now confirm the file's items
survived to be checked. Legitimate crate-level attributes (`#![allow(dead_code)]`,
`#![no_std]`) are unaffected.

If a run stopped passing when you upgraded to v0.8.1, the gate was being
defeated. `SandboxEvent::gate_phase_failed` in the trace says which phase failed
— `subject-compile`, `criterion-compile`, `test-run`, or `subject-emptied`.

### Trace and resume

Every step's prompt, decision, tool call, and token usage is persisted. Read the
full trace back, and resume an interrupted run under its original id instead of
restarting:

```rust
use io_harness::{resume, Store};

// After a crash or a hit budget, continue the same run from where it stopped.
let store = Store::open("runs.db")?;
let result = resume(&contract, &provider, &store, run_id).await?;

for step in store.steps(result.run_id)? {
    println!("step {}: {} ({} tokens)", step.step, step.decision, step.tokens);
}
```

Or run it live end to end: `cargo run --example edit_file`.

## Permissions and approval (v0.4)

A `Policy` is a stack of named layers plus a per-action default. It is evaluated
**deny-first across the whole stack**: a deny in any layer beats an allow in any
other, so a layer can add capability but can never re-allow what a layer beneath
it denied.

```rust
use io_harness::{run_with, ApproveAll, Policy, Store, TaskContract, Verification};

let policy = Policy::default()          // reads open, writes ask, secrets denied
    .layer("project")
    .allow_read("*")
    .deny_read("secrets/*")
    .deny_write("secrets/*");

let result = run_with(&contract, &provider, &store, &policy, &ApproveAll).await?;

// Why was that refused? Same function the tool layer enforces with.
let verdict = policy.explain(io_harness::Act::Write, "secrets/key.txt");
println!("{:?} by rule {:?} in layer {:?}", verdict.effect, verdict.rule, verdict.layer);
```

**The default is permissive.** A caller who passes no policy — plain `run()` —
gets no enforcement and the exact 0.3.0 behaviour. The boundary is opt-in. This
is a deliberate trade-off for backward compatibility, not an oversight.

### What asks, what is refused

`Policy::default()` sets the tiers, following the same shape Claude Code uses:

| Action | Default | Note |
| --- | --- | --- |
| Read | allow | `.env`, `*.pem`, `id_rsa`, `id_ed25519`, `*.key` denied outright |
| Write | **ask** | including overwriting a file the path rules already allow |
| Exec | ask | `rustc` and `<test-binary>` allowed, so verification works |

A **denied** action never reaches the approver — it is refused and reported to
the model as a tool result it can adapt to, and the refusal consumes a step, so
a model retrying it reaches the step cap rather than looping. Only the
**ask** tier prompts.

### The approver

```rust
use io_harness::approve::{Approver, Decision, DecisionFuture, Request};

impl Approver for MyUi {
    fn decide<'a>(&'a self, request: &'a Request) -> DecisionFuture<'a> {
        Box::pin(async move {
            match self.ask_the_human(request).await {
                Answer::Yes      => Decision::approve(),
                Answer::No       => Decision::deny("not this one"),
                Answer::NotNow   => Decision::Defer,   // persist and decide later
            }
        })
    }
}
```

The trait is object-safe (`Box<dyn Approver>`) and the future may stay pending
indefinitely — the run waits rather than timing out. `Decision::Approve` can
also carry a rewritten action (`modified`) or rules to `remember` for the rest
of the run. Both are re-checked against the policy: **an approval cannot move an
action across a deny, and a remembered allow cannot override one.**
Remembered rules come back on `RunResult::remembered` for you to persist.

Built-ins: `ApproveAll`, `DenyAll`, `StdinApprover`.

### Deferring past the end of the process

```rust
match result.outcome {
    RunOutcome::AwaitingApproval { request_id, .. } => {
        // ...hours later, another process, same rusqlite file
        let store = Store::open("runs.db")?;
        io_harness::resume_with_decision(
            &contract, &provider, &store, run_id, request_id,
            Decision::approve(), &policy, &approver,
        ).await?
    }
    _ => result,
};
```

The pending action is persisted with the content the human was shown, so the
resumed action is exactly the one approved. The policy is re-checked on resume,
so a deny that landed while it waited still holds.

### Sharing one policy between apps

`Policy` is `serde`-serializable, so io-cli and io-studio read the same format
and neither writes its own parser. Compose layers with `merge`:

```rust
let effective = shared_base.merge(app_local);
```

The recommended convention is **user base → project layer → app overlay**, each
app keeping its own config file over a shared base. The crate composes a stack
it is handed; **it does not discover config files** — locations and precedence
are the adopting app's responsibility. Because denies are absolute across
layers, a shared base stays trustworthy no matter what an app stacks on top.

Run it live: `cargo run --example policy_run`.

## Agent composition and containment (v0.5)

A single loop does work one step wide. For large or parallelisable tasks, a
**parent agent decomposes the work and spawns sub-agents** — up to 100+ at once —
each running the same observe/reason/act/verify/stop loop over the **same
workspace and the same trace**. A child's result composes back so the parent
continues from what it produced, and children may nest.

Sub-agents are **opt-in**: only [`run_tree`] offers the `spawn_agent` tool. Pass a
`Containment` and the tree runs under it.

```rust
use io_harness::{run_tree, ApproveAll, Containment, Policy, Store, TaskContract, Verification};

# async fn demo() -> io_harness::Result<()> {
let provider = io_harness::OpenRouter::from_env()?;
let store = Store::memory()?;

let contract = TaskContract::workspace(
    "Coordinate: delegate each file to a sub-agent, then combine.",
    "path/to/workspace",
    Verification::WorkspaceFileContains { file: "summary.txt".into(), needle: "DONE".into() },
);

// Caps for the WHOLE tree — no spawned task can raise them.
let containment = Containment::new(
    /* max_total_agents */ 100,
    /* max_concurrent   */ 16,
    /* max_depth         */ 3,
    /* max_total_tokens  */ 500_000,
);

let result = run_tree(&contract, &provider, &store, &Policy::permissive(), &ApproveAll, &containment).await?;
# Ok(())
# }
```

### Containment is inherit-and-narrow

The 0.4 policy becomes the boundary for spawned agents. Where `Policy::merge`
lets an overlay *widen* a base (allows union), `Policy::contain` derives a
**child** policy that can only *narrow*:

- **denies union** downward — a child adds restrictions;
- **allows intersect** downward — a child can never read, write, or execute
  anything its parent could not;
- the rule holds at **any depth** — no descendant can hold an effective allow the
  root did not grant.

```rust
let child_effective = parent_policy.contain(&child_overlay); // child cannot widen
```

### One spend ceiling above the task contract

The whole tree draws its token spend from **one shared ledger**. A spawned
`TaskContract` can set a *tighter* budget but never a looser one than the tree has
left; when the aggregate `max_total_tokens` is reached the tree halts as a whole.
A spawn that would breach any cap — agents, depth, or budget — is **refused** as a
tool result the parent can adapt to, and every spawn, refusal, and budget draw is
in the rusqlite trace as one reconstructable graph.

Run it live: `cargo run --example subagents`.

## Execution sandbox (v0.6)

Since v0.2 the verification gate has compiled and run model-produced code. Until
v0.6 that ran **directly on the host** — the "compiles locally, no isolation"
limitation, made sharper by v0.5's many concurrent agents. v0.6 routes every such
execution through a `Sandbox`:

- **Ephemeral workdir** — created per run and **destroyed** on every exit path
  (success, failure, cap kill), so nothing it writes or spawns outlives it.
- **Resource caps that kill, not throttle** — `SandboxLimits` caps CPU time,
  wall-clock, and memory; a breach returns a *typed* cap hit, never a hang.
- **Network denied by default** — a configurable egress allow-list is deferred to
  v0.8; v0.6 is deny-by-default only.
- **Trace** — every create, the argv and the backend that ran it, each cap hit,
  and each teardown land in the rusqlite trace, so an operator can audit *where*
  each piece of code ran and *how* it was isolated.

### OS-native and OS-neutral

One trait, a real native backend per platform, over a portable floor that runs
everywhere — so a task isolates the same way on mac, linux, and windows:

| Backend | Isolation |
| --- | --- |
| **macOS `sandbox-exec`** | profile confines writes to the workdir and **denies network**; rlimits cap CPU/fds; an RSS monitor caps memory (macOS does not enforce address-space rlimits) |
| **Linux namespaces** | user/mount/pid/**net** namespaces — a *hard* network boundary and a private root — plus rlimits *(cfg-gated; compiled + unit-tested, not live-run on the macOS build host)* |
| **Windows** | **no native backend yet** — the Job Object is designed but unimplemented (no Win32 call is made), so a Windows run gets the portable floor and reports it as such. Tracked for a dedicated release |
| **Portable floor** | the guaranteed minimum on every OS: fresh subprocess, ephemeral workdir, resource caps, network env stripped. Deliberately the **weakest** backend — filesystem-scoped and resource-capped, *not* a full syscall jail |

`select` picks the strongest backend available on the running OS and records which
ran. Sandboxing is the **new default** for the verification gate and is transparent
to it — the same code passes or fails as before — and a caller who wants the exact
v0.5 direct-host execution can opt it off, so the change is additive and reversible.

Run it live: `cargo run --example sandbox_run`.

## Durable, unattended runs (v0.7)

Start a long task and walk away. v0.7 makes a run survive a crash or a full
process restart, so the harness can run unattended for a long horizon (24h+) with
no user input and pick up exactly where it stopped.

- **Checkpoint after every step, transactionally** — each completed step's trace
  row, its budget draw, and a checkpoint marker are committed in one rusqlite
  transaction. The committed checkpoint *is* the step's completion marker: a crash
  mid-commit leaves either a whole step or none of it, never a torn half.
- **Resume the whole tree** — `resume` continues a single or workspace run;
  `resume_tree` reconstructs a crashed v0.5 tree and continues **every** agent from
  its own last committed step. A parent *adopts* the children it had already
  spawned and resumes each from its checkpoint, rather than duplicating or
  restarting them.
- **Idempotent by construction** — a completed step is skipped (recorded as a
  `skipped` event), the aggregate `Ledger` budget is restored from durable totals
  (never reset, never double-charged), the time budget counts real wall-clock
  elapsed across the downtime, an already-applied edit is re-observed rather than
  repeated, and re-running a resume is a no-op.
- **Approval survives a restart** — a v0.4 sensitive action that pauses the tree
  outlives the process; a fresh process delivers the decision with
  `resume_tree_with_decision` and the tree continues.
- **Sandboxes are re-created, never resumed** — an ephemeral v0.6 sandbox is never
  checkpointed, so an exec in flight at crash time simply re-runs in a fresh
  sandbox; a committed sandboxed step is skipped.
- **Typed failure** — a resume against a newer-format or missing checkpoint is an
  `Error::Resume`, not a panic or a half-resume.

The 24h horizon is proven by a real `kill -9`-then-resume test plus a time-scaled
long unattended run; a literal 24h wall-clock run is noted, not gated on.

Run it live: `cargo run --example durable_run` (kills itself mid-run and resumes).

## MCP and network egress (v0.8)

Point the harness at MCP servers and their tools reach the agent beside the
built-ins. Because a configured server is the first thing here that can dial an
arbitrary host, the v0.4 policy grows a fourth act at the same time.

```rust
use io_harness::{run_with, ApproveAll, McpServer, OpenRouter, Policy, Store,
                 TaskContract, Verification};

let contract = TaskContract::workspace(
    "summarise the repo's README into NOTES.md",
    "/path/to/repo",
    Verification::WorkspaceFileContains { file: "NOTES.md".into(), needle: "#".into() },
)
.with_mcp([
    McpServer::stdio("files", "my-mcp-file-server"),
    McpServer::http("search", "https://mcp.example.com/mcp"),
]);

let policy = Policy::default()
    .layer("app")
    .allow_read("*")
    .allow_write("*")
    // The stdio server may start; nothing else may be executed.
    .allow_exec("my-mcp-file-server")
    // The HTTP server may be reached; every other host stays denied.
    .allow_net("mcp.example.com");

let result = run_with(&contract, &provider, &store, &policy, &ApproveAll).await?;
```

- **Namespaced tools** — a server's tools arrive as `mcp__<server>__<tool>`, so a
  server advertising `write_file` cannot shadow the built-in. Both stay callable
  and distinct.
- **Two transports** — `McpServer::stdio` spawns a child process,
  `McpServer::http` dials a streamable-HTTP endpoint. One session serves a whole
  v0.5 agent tree, not one connection per agent.
- **`Act::Net`** — an outbound connection is a policy decision with a target
  (`host` or `host:port`), matched by the same glob matcher as paths and binaries
  and decided by the same deny-first stack: `allow_net`, `deny_net`, `ask_net`. An
  `ask_net` routes to the `Approver` and, if deferred, survives a full process
  restart like any other v0.4 approval.
- **Your provider still works under deny-all** — the harness contributes its
  configured provider's host as a *named* layer, `provider`, so you need not list
  it. `Policy::explain` attributes the allowance to that layer rather than hiding
  it. An explicit `deny_net` of your own provider still wins, and fails fast as a
  refusal rather than hanging.
- **Two gates per server** — starting a stdio server is an exec check on its
  binary; calling one of its tools is an exec check on the namespaced tool name.
  So a policy can allow a server generally and still deny one of its tools.
- **Contained downward** — a v0.5 child inherits its parent's network rules and can
  only narrow them; the spawn tool takes `deny_net` alongside `deny_write`.
- **Everything is traced** — connects (with transport), tools discovered, each call
  with latency and outcome, and every network verdict with the layer that decided
  it. A 0.7.0 database migrates in place.

### What a refusal looks like

An out-of-policy **tool call** is an observation the model can adapt to, not a
crashed run — the same treatment a refused path already gets:

```text
[mcp__files__delete_everything refused] (rule mcp__files__delete_*) — the policy forbids calling this tool
```

A denied **host**, or a configured server that will not start, stops the run
before anything happens, with `Error::Refused { act: "net", .. }` or
`Error::Mcp { server, reason }` — because unlike a single tool call, there is no
useful way for the agent to work around a capability it was told it had.

### The limit, stated plainly

The harness governs the connections **it** opens. A stdio MCP server is a separate
process: the harness decides whether it may start and which of its tools may be
called, but once running it dials whatever it likes. Isolating a server's own
egress would need OS-level containment, which v0.8 does not build.

## In-process tools and skills (v0.9)

v0.8 made the harness extensible **out of process**. That is the right boundary
for a capability that already lives elsewhere and the wrong one for a capability
already linked into the same binary: a second process, a transport, and a
serialization hop to call a function that is one `await` away. v0.9 adds the
in-process half — implement `Tool` for something your product already knows how
to do — plus **skills**, markdown instruction files that shape *how* the agent
works without touching Rust.

### Register a tool your program already has

```rust
use io_harness::tools::{Tool, ToolFuture, Toolbox};
use io_harness::{run_with, ApproveAll, Policy, TaskContract, ToolSpec, Verification};
use serde_json::json;

struct LookupOrder { db: OrderDb }

impl Tool for LookupOrder {
    fn spec(&self) -> ToolSpec {
        ToolSpec {
            name: "lookup_order".into(),
            description: "Look up an order by id. Returns its status and line items.".into(),
            parameters: json!({
                "type": "object",
                "properties": { "id": { "type": "string" } },
                "required": ["id"]
            }),
        }
    }

    fn invoke<'a>(&'a self, arguments: &'a serde_json::Value) -> ToolFuture<'a> {
        // Read defensively: a model can send a missing or mistyped field, and
        // that is an observation to adapt to, not a crash.
        let id = arguments.get("id").and_then(|v| v.as_str()).unwrap_or("").to_string();
        Box::pin(async move {
            Ok(match self.db.order(&id).await {
                Some(order) => format!("{order}"),
                None => format!("no order with id {id:?}"),
            })
        })
    }
}

let contract = TaskContract::workspace(
    "Write the status of order 4471 into REPORT.md.",
    "/path/to/repo",
    Verification::WorkspaceFileContains { file: "REPORT.md".into(), needle: "4471".into() },
)
.with_tools(Toolbox::new().with(LookupOrder { db }));

let policy = Policy::default()
    .layer("app")
    .allow_read("*")
    .allow_write("*")
    // Registering the tool offers it. This is what allows it to be called.
    .allow_exec("lookup_order");

let result = run_with(&contract, &provider, &store, &policy, &ApproveAll).await?;
```

- **Registration is availability, not authority** — a call is an `Act::Exec`
  check on the tool's name, decided by the same deny-first stack that decides
  paths, binaries, and hosts. Hand an agent a toolbox and still refuse one tool
  in it; the refusal is an observation the agent adapts to, with the deciding
  rule and layer in the trace. An `ask_exec` routes to the `Approver` and
  survives a restart like any other v0.4 approval.
- **Nothing may shadow anything** — a registered tool cannot take the name of a
  built-in (`write_file`, `grep`, `find`, `read_file`, `spawn_agent`,
  `read_skill`), cannot use the `mcp__` prefix reserved for server tools, and two
  registered tools cannot share a name. Each is an `Error::Config` raised
  **before the provider is called once**, not a silent shadowing found at
  dispatch.
- **A failing tool is an observation** — returning `Err` puts the message in the
  observations and the run continues, the same treatment `grep` gives a bad
  regex. Only the model can tell "try another id" from "give up on this
  approach".
- **It cannot flood the context** — a result over the cap is truncated with a
  visible marker before it enters the observations, and the truncated form is
  what the trace records.
- **Inherited by the tree** — a v0.5 child is offered its parent's toolbox and
  calls it under the child's own narrowed policy. Inheritance grants the tool;
  `Policy::contain` still decides the call.
- **Traced like a built-in** — same decision, argument, and observation rows, so
  an audit does not have to distinguish extension from core.

Run it live: `cargo run --example custom_tool`.

### Skills: instructions, not code

Point the contract at a directory of markdown. Both conventions in common use
are accepted, so a directory written for another agent tool usually works
unchanged:

```text
skills/
  migrations.md          -> skill "migrations"
  api-style/
    SKILL.md             -> skill "api-style"
```

```rust
let contract = TaskContract::workspace(
    "Add the `orders` table migration.",
    "/path/to/repo",
    Verification::EachCompilesRust(vec!["migrations/003_orders.rs".into()]),
)
.with_skills("skills");   // discovered once per run, not once per step
```

Optional YAML frontmatter names and describes a skill; without it the name comes
from the file stem (or the containing directory, for a `SKILL.md`) and the
description from the first prose line:

```text
---
name: migrations
description: How to write a reversible database migration in this repo.
---

Always write the down-migration first...
```

- **Names and descriptions reach the prompt; bodies do not** — twenty skills
  would otherwise be paid for on every turn of every run. The agent is told what
  exists and calls the built-in `read_skill` tool for the one it judges
  relevant, which enters the observations once. The harness does not rank,
  match, or auto-inject — automatic relevance selection is a context-construction
  question and is deliberately not here.
- **Reading one is an ordinary policy-checked read** — a policy denying
  `Act::Read` over the skills directory keeps the catalogue in the prompt and the
  bodies out of the context, with the refusal in the trace. An unknown skill name
  returns an observation listing what does exist, not an error.
- **A bad directory fails honestly** — a missing path, a path that is not a
  directory, more than 64 skills, or two skills with the same name is an
  `Error::Config` at run start. A rejected set, not a silently truncated one the
  caller believes is complete.

Run it live: `cargo run --example skills_run`.

### The boundary, stated plainly

A registered tool runs **in the harness's own process, with the embedding
program's privileges**. The policy governs whether it is *called*; it does not
govern what it does once running — no sandbox, no path scoping, and no egress
control applies inside it. This is exactly the bound v0.8 already states for a
stdio MCP server, and for the same reason: the harness decides what starts, not
what a started thing then does. A tool that shells out, writes outside the
workspace, or dials a host has done so with your full authority.

A **skill** is instructions with no execution of its own. A skill saying "run
`rm -rf /`" is a sentence the model reads, and any action it then takes passes
the same policy every other action does. Anything that should actually *do*
something is a `Tool`, where the permission layer can see it.

## Part of initorigin

`IO Harness` is one of the [initorigin](https://github.com/initorigin) products:

| Repo | What it is |
| --- | --- |
| [io-harness](https://github.com/initorigin/io-harness) | The Rust agent harness (the center product) |
| [io-eval](https://github.com/initorigin/io-eval) | Benchmark harness for io-harness |
| [io-cli](https://github.com/initorigin/io-cli) | Terminal app on io-harness |
| [io-studio](https://github.com/initorigin/io-studio) | Desktop coding studio on io-harness |
| [website](https://github.com/initorigin/website) | Marketing site, docs, and blog |

## Contributing

Read [CONTRIBUTING.md](CONTRIBUTING.md). Work branches from `develop`, lands via
PR, and every user-facing change updates [CHANGELOG.md](CHANGELOG.md). Releases
follow [docs/RELEASE_PROCESS.md](docs/RELEASE_PROCESS.md).

## Security

Report vulnerabilities per [SECURITY.md](SECURITY.md).

## License

Apache-2.0. Copyright 2026 Aakash Pawar (InitOrigin). See [LICENSE](LICENSE) and
[NOTICE](NOTICE).