io-harness 0.30.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
//! Shaping a run from `io.toml` — F1 through F6 of 0.28.0.
//!
//! Everything here is driven through the real loader and the real run loop with
//! the scripted mock provider the rest of the suite uses. A hook is only worth
//! anything if it fires on the events the loop actually emits, so nothing in this
//! file constructs a `RunEvent` by hand except where it is asserting a property of
//! the filter rather than of the run.

use std::collections::BTreeSet;
use std::path::Path;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::{Mutex, OnceLock};

use io_harness::observe::{Flow, Observer, RunEvent};
use io_harness::provider::{CompletionRequest, CompletionResponse, ToolCall, Usage};
use io_harness::{
    run_with_observed, ApproveAll, Config, Policy, Provider, Store, TaskContract, Verification,
};
use serde_json::json;

/// One empty directory for the whole binary, so every test in it points the user
/// scope at the same place.
///
/// `tests/config.rs` guards the same variable with a mutex because its tests set it
/// to *different* directories and the process has one environment. Here every test
/// wants the same answer — an empty user scope, so a config file on the developer's
/// own machine cannot change what these tests measure — so one shared directory
/// removes the race rather than serializing around it. Which matters: half of these
/// tests are `async`, and a lock held across an `.await` is a lint and a deadlock
/// waiting for a reason.
static USER: OnceLock<tempfile::TempDir> = OnceLock::new();

fn empty_user_scope() {
    let dir = USER.get_or_init(|| tempfile::tempdir().unwrap());
    std::env::set_var("IO_CONFIG_HOME", dir.path());
}

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

/// Answers with one scripted tool call per turn and nothing afterwards.
struct Mock {
    script: Vec<Vec<ToolCall>>,
    at: AtomicUsize,
}

impl Provider for Mock {
    async fn complete(&self, _req: CompletionRequest) -> io_harness::Result<CompletionResponse> {
        let i = self.at.fetch_add(1, Ordering::SeqCst);
        Ok(CompletionResponse {
            tool_calls: self.script.get(i).cloned().unwrap_or_default(),
            usage: Some(Usage {
                total_tokens: 7,
                ..Default::default()
            }),
            ..Default::default()
        })
    }

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

fn mock(script: Vec<Vec<ToolCall>>) -> Mock {
    Mock {
        script,
        at: AtomicUsize::new(0),
    }
}

fn call(name: &str, args: serde_json::Value) -> ToolCall {
    ToolCall {
        name: name.into(),
        arguments: args,
    }
}

/// Forwards every event to two observers, so the same run can be watched by the
/// hooks under test and by a recorder that says what the run actually emitted.
/// F1's first control is an equality between those two sets, and it needs both
/// halves of one run rather than two runs that were probably the same.
struct Tee<'a>(&'a dyn Observer, &'a dyn Observer);

impl Observer for Tee<'_> {
    fn event(&self, event: &RunEvent) -> Flow {
        let a = self.0.event(event);
        let b = self.1.event(event);
        if a.is_cancel() || b.is_cancel() {
            Flow::Cancel
        } else {
            Flow::Continue
        }
    }
}

#[derive(Default)]
struct Tags(Mutex<Vec<String>>);

impl Observer for Tags {
    fn event(&self, event: &RunEvent) -> Flow {
        let v = serde_json::to_value(event).unwrap();
        self.0
            .lock()
            .unwrap()
            .push(v["event"].as_str().unwrap().to_string());
        Flow::Continue
    }
}

impl Tags {
    fn set(&self) -> BTreeSet<String> {
        self.0.lock().unwrap().iter().cloned().collect()
    }
}

/// A run that tries one write it is not allowed to make and then stops, so both a
/// `refused` and a `finished` are reached without a socket.
fn contract(root: &Path) -> TaskContract {
    TaskContract::workspace("exercise the hooks", root)
        .with_verification(Verification::WorkspaceFileContains {
            file: "unreachable.txt".into(),
            needle: "never".into(),
        })
        .with_max_steps(2)
}

/// Reads anything and writes nothing, and *denies* rather than asks: an `Ask`
/// default reaches `ApproveAll` and the write happens, which is an approval and not
/// a refusal. The distinction is the point of the test.
fn read_only() -> Policy {
    Policy::default()
        .layer("test")
        .allow_read("*")
        .deny_write("*")
}

/// The tags in an audit file, in the order they were written.
fn logged(at: &Path) -> Vec<String> {
    std::fs::read_to_string(at)
        .unwrap()
        .lines()
        .map(|l| {
            serde_json::from_str::<serde_json::Value>(l).unwrap()["event"]
                .as_str()
                .unwrap()
                .to_string()
        })
        .collect()
}

// ---------------------------------------------------------------------------
// F1 — a hook fires on the events it names and on no others
// ---------------------------------------------------------------------------

#[tokio::test]
async fn f1_a_hook_fires_on_the_events_it_names_and_on_no_others() {
    empty_user_scope();
    let ws = tempfile::tempdir().unwrap();

    std::fs::write(
        ws.path().join("io.local.toml"),
        r#"
        [[hook]]
        on = ["refused", "finished"]
        append = "named.jsonl"

        [[hook]]
        append = "everything.jsonl"

        [[hook]]
        on = ["question_asked"]
        append = "never.jsonl"
        "#,
    )
    .unwrap();

    let hooks = Config::discover(ws.path()).unwrap().hooks();
    let tags = Tags::default();
    let store = Store::open(ws.path().join("s.db")).unwrap();

    run_with_observed(
        &contract(ws.path()),
        &mock(vec![vec![call(
            "write_file",
            json!({"path": "a.txt", "content": "hi"}),
        )]]),
        &store,
        &read_only(),
        &ApproveAll,
        &Tee(&hooks, &tags),
    )
    .await
    .unwrap();

    // The hook that named two events got those two and nothing else.
    let named: BTreeSet<String> = logged(&ws.path().join("named.jsonl")).into_iter().collect();
    assert_eq!(
        named,
        BTreeSet::from(["refused".to_string(), "finished".to_string()]),
        "a filtered hook received something it did not ask for, or missed something it did"
    );

    // Control one: no `on` is every event, asserted as an equality against what the
    // run emitted rather than as "the file is not empty".
    let everything: BTreeSet<String> = logged(&ws.path().join("everything.jsonl"))
        .into_iter()
        .collect();
    assert_eq!(
        everything,
        tags.set(),
        "an unfiltered hook must see exactly the run's own event stream"
    );

    // Control two: a filter that matched nothing leaves an empty file rather than no
    // file, so "matched nothing" stays distinguishable from "never installed".
    let never = ws.path().join("never.jsonl");
    assert!(never.exists(), "an installed hook creates its log");
    assert_eq!(std::fs::read_to_string(&never).unwrap(), "");
}

// ---------------------------------------------------------------------------
// F2 — an event the crate does not emit is refused at load
// ---------------------------------------------------------------------------

#[test]
fn f2_an_event_this_crate_does_not_emit_is_refused_naming_it() {
    empty_user_scope();
    let ws = tempfile::tempdir().unwrap();

    std::fs::write(
        ws.path().join("io.local.toml"),
        "[[hook]]\non = [\"finshed\"]\nappend = \"a.jsonl\"\n",
    )
    .unwrap();

    let err = Config::discover(ws.path()).unwrap_err();
    let text = err.to_string();
    assert!(text.contains("finshed"), "{text}");
    assert!(text.contains("hook[0]"), "{text}");
    assert!(text.contains("io.local.toml"), "{text}");

    // The control is in `src/hooks.rs`: every name the crate emits is accepted, so a
    // rule written against a hand-typed subset fails there rather than here.
}

// ---------------------------------------------------------------------------
// F4 — an executing hook is a fixed argv, is handed the event, and is bounded
// ---------------------------------------------------------------------------

/// A program that reads its stdin into a file whose name carries `;` and `&&`.
///
/// Both halves of F4 in one child. The file name proves the argv element arrived
/// **whole**: this crate never hands a string to a shell, so what an operator wrote
/// as one array element reaches the process as one argument, metacharacters and
/// spaces and all. The file's contents prove the event reached stdin and that what
/// arrived is the JSON of the event that fired.
///
/// Each platform's own programs are named rather than the test being skipped on
/// Windows, which is the lesson 0.27.0's F4 paid for. The shell here is the
/// *operator's* choice of program, which is a different thing from this crate
/// inserting one.
/// PowerShell rather than `cmd` on Windows, and the reason is a property of `cmd`
/// rather than of this crate: `cmd /c` strips the quotes a spawn adds around a
/// single argument only when that argument contains no other quotes, and keeps them
/// otherwise — so a `/c` string carrying a quoted redirect target is read as the
/// name of a program. PowerShell parses its own command line and does not have that
/// rule, which lets the same script text carry `;`, `&&` and a space on both
/// platforms and keeps this test asserting the same thing everywhere.
///
/// The shell here is the *operator's* choice of program, which is a different thing
/// from this crate inserting one.
#[cfg(unix)]
const CAPTURE: &[&str] = &["sh", "-c", "cat > 'a;b && c.jsonl'"];
#[cfg(windows)]
const CAPTURE: &[&str] = &[
    "powershell",
    "-NoProfile",
    "-NonInteractive",
    "-Command",
    "[Console]::In.ReadToEnd() | Set-Content -LiteralPath 'a;b && c.jsonl'",
];

/// The file the capture hook writes, which is also the proof: an argv split on
/// whitespace, on `;` or on `&&` would produce some other file, or several.
const CAPTURE_FILE: &str = "a;b && c.jsonl";

/// Sleeps well past any timeout this test sets.
#[cfg(unix)]
const SLOW: &[&str] = &["sleep", "30"];
#[cfg(windows)]
const SLOW: &[&str] = &["ping", "-n", "31", "127.0.0.1"];

/// Returns at once, and successfully.
#[cfg(unix)]
const FAST: &[&str] = &["true"];
#[cfg(windows)]
const FAST: &[&str] = &["cmd", "/c", "exit 0"];

/// Returns at once, and unsuccessfully.
#[cfg(unix)]
const FAILS: &[&str] = &["false"];
#[cfg(windows)]
const FAILS: &[&str] = &["cmd", "/c", "exit 1"];

/// A TOML array literal for one of the tables above.
fn argv(parts: &[&str]) -> String {
    let items: Vec<String> = parts.iter().map(|p| format!("{p:?}")).collect();
    format!("[{}]", items.join(", "))
}

/// Run one turn under one hook table and report the outcome.
async fn run_under(ws: &Path, hook: &str) -> io_harness::RunOutcome {
    std::fs::write(ws.join("io.local.toml"), hook).unwrap();
    let hooks = Config::discover(ws).unwrap().hooks();
    let store = Store::open(ws.join("s.db")).unwrap();
    run_with_observed(
        &contract(ws),
        &mock(vec![vec![call(
            "read_file",
            json!({"path": "io.local.toml"}),
        )]]),
        &store,
        &read_only(),
        &ApproveAll,
        &hooks,
    )
    .await
    .unwrap()
    .outcome
}

#[tokio::test]
async fn f4_an_executing_hook_gets_its_argv_whole_and_the_event_on_stdin() {
    empty_user_scope();
    let ws = tempfile::tempdir().unwrap();

    run_under(
        ws.path(),
        &format!(
            "[[hook]]\non = [\"started\"]\nrun = {}\ntimeout_ms = 20000\n",
            argv(CAPTURE)
        ),
    )
    .await;

    // The argument arrived whole. A harness that split on whitespace, or handed the
    // string to a shell, would have produced some other file — or several.
    let at = ws.path().join(CAPTURE_FILE);
    assert!(
        at.is_file(),
        "the argv element did not reach the child intact: {:?}",
        std::fs::read_dir(ws.path())
            .unwrap()
            .filter_map(|e| e.ok().map(|e| e.file_name()))
            .collect::<Vec<_>>()
    );

    // And what reached its stdin is the event that fired, as JSON rather than as a
    // rendering of one.
    let text = std::fs::read_to_string(&at).unwrap();
    // A byte-order mark first: PowerShell's `Set-Content` may write one, and a BOM
    // is whitespace to nobody, least of all to a JSON parser.
    let v: serde_json::Value =
        serde_json::from_str(text.trim_start_matches('\u{feff}').trim()).unwrap();
    assert_eq!(v["event"], "started");
    assert_eq!(v["run_id"], 1);
}

/// The bound, and the control that says "bounded" is not being satisfied by an
/// implementation that kills everything. A killed hook is a failed hook, and the
/// only way a failure is observable from out here is `on_failure = "cancel"` — so
/// the two criteria are asserted through one another rather than through a log line.
#[tokio::test]
async fn f4_a_hook_that_outlives_its_timeout_is_killed_and_reported_as_a_failure() {
    empty_user_scope();

    let slow = tempfile::tempdir().unwrap();
    let outcome = run_under(
        slow.path(),
        &format!(
            "[[hook]]\non = [\"started\"]\nrun = {}\ntimeout_ms = 50\non_failure = \"cancel\"\n",
            argv(SLOW)
        ),
    )
    .await;
    assert!(
        matches!(outcome, io_harness::RunOutcome::Cancelled { .. }),
        "a hook past its deadline is a failure: {outcome:?}"
    );

    // The negative control: the same shape inside its timeout completes, is not a
    // failure, and does not stop the run.
    let fast = tempfile::tempdir().unwrap();
    let outcome = run_under(
        fast.path(),
        &format!(
            "[[hook]]\non = [\"started\"]\nrun = {}\ntimeout_ms = 30000\non_failure = \"cancel\"\n",
            argv(FAST)
        ),
    )
    .await;
    assert!(
        !matches!(outcome, io_harness::RunOutcome::Cancelled { .. }),
        "a hook that succeeded must not stop the run: {outcome:?}"
    );
}

// ---------------------------------------------------------------------------
// F5 — a hook can stop a run, and by default cannot
// ---------------------------------------------------------------------------

#[tokio::test]
async fn f5_a_failing_hook_stops_the_run_only_when_the_operator_asked_it_to() {
    empty_user_scope();

    let asked = tempfile::tempdir().unwrap();
    let outcome = run_under(
        asked.path(),
        &format!(
            "[[hook]]\non = [\"started\"]\nrun = {}\non_failure = \"cancel\"\n",
            argv(FAILS)
        ),
    )
    .await;
    assert!(
        matches!(outcome, io_harness::RunOutcome::Cancelled { .. }),
        "a local policy check that says no must end the run: {outcome:?}"
    );

    // The negative control, and the whole reason the key exists: the byte-identical
    // hook without `on_failure` leaves the run to reach its own ending. A
    // notification that happens to fail is not a kill switch.
    let unasked = tempfile::tempdir().unwrap();
    let outcome = run_under(
        unasked.path(),
        &format!("[[hook]]\non = [\"started\"]\nrun = {}\n", argv(FAILS)),
    )
    .await;
    assert!(
        matches!(outcome, io_harness::RunOutcome::StepCapReached { .. }),
        "the default is continue: {outcome:?}"
    );
}