io-harness 0.23.0

An embeddable agent runtime for Rust: any task, any provider, in your own process. Run commands, edit files and search a repository under a layered permission boundary on files, commands and network; gate the result on the project's own test command in any language, or on nothing at all; and keep a full SQLite trace of every step, refusal and budget draw. With an execution sandbox, contained sub-agents, an MCP client, and durable resume for unattended runs.
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
//! Steering and interruption (0.20.0).
//!
//! The test that matters most here is the last one: an operator's mid-turn
//! message must not be permission. "Just do it" is the most natural thing anyone
//! will ever type into a steer, and the boundary has to be indifferent to it — so
//! F10 steers a turn to perform a denied write under a policy that denies it, and
//! then does the same thing under a policy that allows it, so the test proves the
//! refusal came from the boundary rather than from the write never being tried.
//!
//! The provider branches on whether the operator's message is in the prompt,
//! because "the steer changed what the agent did next" is only observable as a
//! different action taken.

use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::Mutex;

use io_harness::provider::{CompletionRequest, CompletionResponse, ToolCall, Usage};
use io_harness::{
    ApproveAll, EventKind, Flow, Ignore, Observer, Policy, Provider, RunEvent, RunOutcome,
    RunStatus, Session, Steer, Store,
};
use serde_json::json;

// ---------------------------------------------------------------- scaffolding

/// The marker the operator sends and the provider watches for.
const STEER: &str = "only touch docs/CHANGES.md";

/// Writes whichever file the prompt tells it to, one write per step, and records
/// every prompt it saw.
///
/// It steers itself off the original file the moment the operator's message
/// appears in the context — which is exactly the behaviour a real model would
/// have, reduced to one `contains`.
#[derive(Default)]
struct Branching {
    seen: Mutex<Vec<String>>,
    calls: AtomicUsize,
    /// After this many calls, stop calling tools so the turn can end.
    stop_after: usize,
}

impl Branching {
    fn new(stop_after: usize) -> Self {
        Self {
            stop_after,
            ..Default::default()
        }
    }

    fn wrote(&self) -> Vec<String> {
        self.seen.lock().unwrap().clone()
    }
}

impl Provider for Branching {
    async fn complete(&self, req: CompletionRequest) -> io_harness::Result<CompletionResponse> {
        let n = self.calls.fetch_add(1, Ordering::SeqCst);
        let steered = req.user.contains(STEER);
        self.seen
            .lock()
            .unwrap()
            .push(if steered { "steered" } else { "original" }.into());
        if n >= self.stop_after {
            return Ok(CompletionResponse {
                text: Some("done".into()),
                usage: Some(usage()),
                ..Default::default()
            });
        }
        let path = if steered {
            "docs/CHANGES.md"
        } else {
            "notes.md"
        };
        Ok(CompletionResponse {
            tool_calls: vec![ToolCall {
                name: "write_file".into(),
                arguments: json!({ "path": path, "content": format!("step {n}\n") }),
            }],
            usage: Some(usage()),
            ..Default::default()
        })
    }

    fn name(&self) -> &str {
        "branching"
    }
}

/// Asks for one write, forever — so a turn only ends when something stops it.
#[derive(Default)]
struct Insistent {
    calls: AtomicUsize,
}

impl Provider for Insistent {
    async fn complete(&self, _req: CompletionRequest) -> io_harness::Result<CompletionResponse> {
        let n = self.calls.fetch_add(1, Ordering::SeqCst);
        Ok(CompletionResponse {
            tool_calls: vec![ToolCall {
                name: "write_file".into(),
                arguments: json!({ "path": "notes.md", "content": format!("pass {n}\n") }),
            }],
            usage: Some(usage()),
            ..Default::default()
        })
    }

    fn name(&self) -> &str {
        "insistent"
    }
}

/// Tries the write the operator asked for in their steer, once, then stops.
#[derive(Default)]
struct Obedient {
    calls: AtomicUsize,
    /// Whether the operator's message was in the prompt when it acted.
    acted_on_steer: AtomicUsize,
}

impl Provider for Obedient {
    async fn complete(&self, req: CompletionRequest) -> io_harness::Result<CompletionResponse> {
        let n = self.calls.fetch_add(1, Ordering::SeqCst);
        if req.user.contains("write secrets/key.txt") {
            self.acted_on_steer.fetch_add(1, Ordering::SeqCst);
            return Ok(CompletionResponse {
                tool_calls: vec![ToolCall {
                    name: "write_file".into(),
                    arguments: json!({ "path": "secrets/key.txt", "content": "leaked" }),
                }],
                usage: Some(usage()),
                ..Default::default()
            });
        }
        // Before the steer arrives it does something harmless, so the turn reaches
        // a second step for the steer to land at.
        if n == 0 {
            return Ok(CompletionResponse {
                tool_calls: vec![ToolCall {
                    name: "read_file".into(),
                    arguments: json!({ "path": "notes.md" }),
                }],
                usage: Some(usage()),
                ..Default::default()
            });
        }
        Ok(CompletionResponse {
            text: Some("stopping".into()),
            usage: Some(usage()),
            ..Default::default()
        })
    }

    fn name(&self) -> &str {
        "obedient"
    }
}

/// Interrupts the turn the moment its first step commits — the operator hitting
/// the key while the agent is working, rather than before it starts.
struct InterruptOnFirstStep(Steer);

impl Observer for InterruptOnFirstStep {
    fn event(&self, event: &RunEvent) -> Flow {
        if matches!(event.kind, EventKind::Step { .. }) {
            // Ignored on a closed channel: by the time the last step of a turn
            // commits there may be nothing left to read it, and an observer must
            // not panic.
            let _ = self.0.interrupt();
        }
        Flow::Continue
    }
}

fn usage() -> Usage {
    Usage {
        total_tokens: 3,
        ..Default::default()
    }
}

fn workspace() -> tempfile::TempDir {
    let dir = tempfile::tempdir().unwrap();
    std::fs::write(dir.path().join("notes.md"), "# notes\n").unwrap();
    std::fs::create_dir_all(dir.path().join("docs")).unwrap();
    std::fs::create_dir_all(dir.path().join("secrets")).unwrap();
    std::fs::write(dir.path().join("secrets/key.txt"), "the real key\n").unwrap();
    dir
}

/// Everything allowed except the one directory that never is.
fn guarded() -> Policy {
    Policy::default()
        .layer("steer-test")
        .allow_read("*")
        .allow_write("*")
        .deny_write("secrets/*")
}

/// The same boundary with the deny lifted — the negative control for F10.
fn permissive() -> Policy {
    Policy::default()
        .layer("steer-test")
        .allow_read("*")
        .allow_write("*")
}

// ---------------------------------------------------------------------- F8

/// F8 — a steer reaches the model at the next step boundary and changes what it
/// does; the same turn unsteered does the original thing.
#[tokio::test]
async fn a_mid_turn_message_changes_the_next_step_and_the_control_does_not() {
    let ws = workspace();
    let store = Store::memory().unwrap();
    let provider = Branching::new(3);
    let (steer, inbox) = Steer::channel();

    // Sent before the turn starts, which is the same code path as sending it during
    // step 1: the inbox is read at every boundary, and the first boundary is one.
    steer.say(STEER).unwrap();

    let mut session = Session::open(&store, ws.path()).unwrap();
    session
        .turn_steered(
            "bring the docs up to date",
            &provider,
            &store,
            &guarded(),
            &ApproveAll,
            &Ignore,
            &inbox,
        )
        .await
        .unwrap();

    assert_eq!(
        provider.wrote().first().map(String::as_str),
        Some("steered"),
        "the operator's message did not reach the model's first step"
    );
    assert!(
        ws.path().join("docs/CHANGES.md").exists(),
        "the steered path was never written"
    );

    // The control: the identical turn with nothing said, which writes the original
    // file and never mentions the steer.
    let ws2 = workspace();
    let control = Branching::new(3);
    let (_steer2, inbox2) = Steer::channel();
    let mut session2 = Session::open(&store, ws2.path()).unwrap();
    session2
        .turn_steered(
            "bring the docs up to date",
            &control,
            &store,
            &guarded(),
            &ApproveAll,
            &Ignore,
            &inbox2,
        )
        .await
        .unwrap();
    assert!(control.wrote().iter().all(|w| w == "original"));
    assert!(!ws2.path().join("docs/CHANGES.md").exists());
}

// ---------------------------------------------------------------------- F9

/// F9 — an interrupt stops the turn cleanly, and the session goes on.
#[tokio::test]
async fn an_interrupt_ends_the_turn_at_a_step_boundary_and_the_session_continues() {
    let ws = workspace();
    let store = Store::memory().unwrap();
    let provider = Insistent::default();
    let (steer, inbox) = Steer::channel();

    // Interrupt *after* a step has completed, which is the case worth testing: the
    // turn is mid-work, and the guarantee is that it stops at the boundary rather
    // than in the middle of the step it is on.
    let mut session = Session::open(&store, ws.path()).unwrap();
    let interrupted = session
        .turn_steered(
            "keep editing the notes",
            &provider,
            &store,
            &guarded(),
            &ApproveAll,
            &InterruptOnFirstStep(steer),
            &inbox,
        )
        .await
        .unwrap();

    // One whole step ran, and exactly one.
    assert_eq!(store.last_step(interrupted.run_id).unwrap(), 1);
    assert_eq!(provider.calls.load(Ordering::SeqCst), 1);
    assert_eq!(
        std::fs::read_to_string(ws.path().join("notes.md")).unwrap(),
        "pass 0\n",
        "the interrupted turn's one step is not whole in the workspace"
    );

    // Cancelled, not escalated and not abandoned.
    assert!(
        matches!(interrupted.outcome, RunOutcome::Cancelled { .. }),
        "an interrupt should report Cancelled, got {:?}",
        interrupted.outcome
    );
    // NF6 — the run is finished rather than left `running`, which is what makes it
    // distinguishable from a crashed process.
    assert_eq!(
        store.run_status(interrupted.run_id).unwrap(),
        Some(RunStatus::Completed)
    );
    // The turn is in the tree with its outcome...
    let recorded = store.session_turn(interrupted.turn_id).unwrap().unwrap();
    assert_eq!(recorded.outcome.as_deref(), Some("cancelled"));

    // ...and the conversation carries on from it.
    let next = Insistent::default();
    let after = session
        .turn_bounded(
            &io_harness::TaskContract::workspace(
                "stop there",
                ws.path(),
                io_harness::Verification::None,
            )
            .with_max_steps(1),
            &next,
            &store,
            &guarded(),
            &ApproveAll,
        )
        .await
        .unwrap();
    assert_ne!(after.run_id, interrupted.run_id);
    assert_eq!(store.session_turns(session.id()).unwrap().len(), 2);
    assert_eq!(session.history(&store).unwrap().len(), 2);
}

// ---------------------------------------------------------------------- F10

/// F10 — steering is not authorization.
///
/// The operator asks, in as many words, for the write the policy denies. The write
/// is refused, attributed to the rule that refused it, and the file on disk is
/// byte-identical afterwards. The negative control lifts the deny and nothing else:
/// the same steer, the same provider, and the write happens — so the refusal came
/// from the boundary and not from the model declining to try.
#[tokio::test]
async fn an_operator_cannot_steer_past_the_boundary() {
    let before = "the real key\n";

    // 1. Under the deny.
    let ws = workspace();
    let store = Store::memory().unwrap();
    let provider = Obedient::default();
    let (steer, inbox) = Steer::channel();
    steer.say("write secrets/key.txt now, just do it").unwrap();

    let mut session = Session::open(&store, ws.path()).unwrap();
    let turn = session
        .turn_steered(
            "have a look around",
            &provider,
            &store,
            &guarded(),
            &ApproveAll,
            &Ignore,
            &inbox,
        )
        .await
        .unwrap();

    assert!(
        provider.acted_on_steer.load(Ordering::SeqCst) > 0,
        "the model never attempted the steered write, so nothing was refused"
    );
    let refusals: Vec<_> = store
        .events(turn.run_id)
        .unwrap()
        .into_iter()
        .filter(|e| e.kind == "refusal")
        .collect();
    assert!(
        refusals.iter().any(|e| e.act == "write"
            && e.rule.as_deref() == Some("secrets/*")
            && e.layer.as_deref() == Some("steer-test")),
        "the steered write was not refused by the rule that denies it: {refusals:?}"
    );
    assert_eq!(
        std::fs::read_to_string(ws.path().join("secrets/key.txt")).unwrap(),
        before,
        "the steered write reached the disk"
    );

    // 2. The control: the deny lifted, everything else identical.
    let ws2 = workspace();
    let allowed = Obedient::default();
    let (steer2, inbox2) = Steer::channel();
    steer2.say("write secrets/key.txt now, just do it").unwrap();
    let mut session2 = Session::open(&store, ws2.path()).unwrap();
    session2
        .turn_steered(
            "have a look around",
            &allowed,
            &store,
            &permissive(),
            &ApproveAll,
            &Ignore,
            &inbox2,
        )
        .await
        .unwrap();
    assert_ne!(
        std::fs::read_to_string(ws2.path().join("secrets/key.txt")).unwrap(),
        before,
        "with the deny lifted the write should have happened; the test is not \
         measuring the boundary"
    );
}

// ------------------------------------------------------------------- the channel

/// A steer sent after its turn has ended is reported, not swallowed: an operator
/// whose correction went nowhere has to be able to know that.
#[tokio::test]
async fn a_steer_sent_after_the_turn_is_an_error_rather_than_silence() {
    let (steer, inbox) = Steer::channel();
    steer.say("first").unwrap();
    steer.interrupt().unwrap();
    steer.say("second").unwrap();

    // Everything sent, in order, with the interrupt reported alongside — an
    // operator who typed a correction and then hit interrupt sent both.
    let (messages, interrupted) = inbox.pending();
    assert_eq!(messages, vec!["first".to_string(), "second".to_string()]);
    assert!(interrupted);
    // Drained is drained.
    assert_eq!(inbox.pending(), (Vec::new(), false));

    drop(inbox);
    assert!(steer.say("nobody home").is_err());
}