io-harness 0.12.0

A Rust agent harness: run an AI agent from a typed task contract to a verified result. Provider-agnostic (OpenRouter, Anthropic, OpenAI) with fallback between them, multi-file edits with grep/find over a workspace, budgets, classified provider failures with kind-aware retry and backoff, stall detection with a bounded replan, 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 sandbox (macOS sandbox-exec, Linux namespaces, portable floor; Windows wall-clock only) isolating model-produced code per run, durable checkpoint/resume for unattended runs, an MCP client (stdio and streamable HTTP), a deny-by-default network egress policy, budget-aware context assembly that compacts superseded observations and re-reads what a later write invalidated, and durable cross-run memory keyed to the workspace. 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
//! The network boundary — every outbound connection the harness opens.
//!
//! Until 0.8 the harness dialled whatever its providers pointed at: the
//! permission model governed reads, writes, and executions, but never "send".
//! MCP is what made that untenable — an operator-configured server is the first
//! caller in the crate that can reach an arbitrary host.
//!
//! Three pieces live here, and they are deliberately the *only* way out:
//!
//! - [`http_client`] is the one `reqwest::Client` constructor in the crate, so
//!   redirect behaviour is decided once rather than per call site.
//! - [`target`] turns a URL into the `host:port` string the policy sees, so
//!   every act sees a target in the same shape.
//! - [`NetGuard`] evaluates that target against a [`Policy`] and records the
//!   verdict, mirroring [`crate::ExecGuard`] so the two boundaries read alike.
//!
//! What this cannot do is govern a connection some *other* process opens. A
//! stdio MCP server is a separate process; the harness decides whether it may
//! start (an [`Act::Exec`] check) and which of its tools may be called, but once
//! running it dials whatever it likes. That limit is real and documented rather
//! than implied away.

use std::time::{Duration, SystemTime};

use crate::error::{Error, Result};
use crate::policy::{Act, Effect, Policy, Verdict};
use crate::state::{PolicyEvent, Store};

/// How long one provider request may take before it is abandoned.
///
/// The trade, named: too short kills a legitimate completion, too long lets one
/// hung socket eat an unattended run. Ten minutes is chosen from the slow end of
/// the *legitimate* side — a full 8192-token stream at a sluggish 15 tokens per
/// second is about nine minutes — so no realistic single completion reaches this
/// deadline. There is no reason to shave it closer: a socket that accepts and
/// then stops writing is caught by *any* finite deadline, and before 0.11.0 there
/// was none at all, so such a socket hung the run forever — no step recorded, so
/// no checkpoint, no ledger draw, and the time budget (checked at the top of a
/// step) never reached.
///
/// A caller who needs a different deadline overrides it per provider with
/// `with_timeout` ([`crate::OpenRouter::with_timeout`],
/// [`crate::Anthropic::with_timeout`], [`crate::OpenAi::with_timeout`]). This
/// module is private, so the value reaches callers re-exported from each of those
/// provider modules — a default you are told to reason about has to be one you
/// can read.
pub const REQUEST_TIMEOUT: Duration = Duration::from_secs(600);

/// The one `reqwest::Client` constructor in the crate.
///
/// Uses [`REQUEST_TIMEOUT`]; see [`http_client_with_timeout`] for the rest.
pub(crate) fn http_client() -> reqwest::Client {
    http_client_with_timeout(REQUEST_TIMEOUT)
}

/// As [`http_client`], with an explicit deadline — for a caller whose model is
/// slower than [`REQUEST_TIMEOUT`] allows, and for tests that need a deadline
/// they can reach in a second rather than ten minutes.
///
/// This function is crate-private; the caller reaches it through the
/// `with_timeout` builder method on each provider, which is what makes the
/// override an actual public affordance rather than a documented one. Until
/// 0.12.0 it was only reachable from inside the crate, so the slow-model case
/// named above had no way in.
///
/// Redirects are **off**. A 3xx is a host change, and a host change after the
/// policy has already decided is a hole in the boundary: the check would have
/// approved `api.example.com` while the bytes went somewhere else. With
/// redirects off the hop surfaces as a non-success status the provider reports,
/// which is a worse error message and a boundary that holds.
///
/// Falls back to `Client::new()` if the builder fails, which it does not do for
/// a configuration this small — the fallback exists so a client is infallible to
/// construct, not because failure is expected.
pub(crate) fn http_client_with_timeout(timeout: Duration) -> reqwest::Client {
    reqwest::Client::builder()
        .redirect(reqwest::redirect::Policy::none())
        .timeout(timeout)
        .build()
        .unwrap_or_else(|_| reqwest::Client::new())
}

/// The wait a response asks for in its `Retry-After` header, if it asks for one.
pub(crate) fn retry_after(headers: &reqwest::header::HeaderMap) -> Option<Duration> {
    parse_retry_after(headers.get("retry-after")?.to_str().ok()?)
}

/// Parse a `Retry-After` value in either form the spec allows: delta-seconds, or
/// an HTTP-date.
///
/// An unparseable value is *absent*, not an error: the header is advice, and
/// failing a call because a server sent a malformed hint about how to retry it
/// would be the header making things worse. A date already in the past means
/// "now", which is what a clock skewed the wrong way looks like.
fn parse_retry_after(value: &str) -> Option<Duration> {
    let value = value.trim();
    if let Ok(secs) = value.parse::<u64>() {
        return Some(Duration::from_secs(secs));
    }
    let at = parse_http_date(value)?;
    Some(
        at.duration_since(SystemTime::now())
            .unwrap_or(Duration::ZERO),
    )
}

/// Parse an IMF-fixdate (`Sun, 06 Nov 1994 08:49:37 GMT`) into a `SystemTime`.
///
/// ponytail: IMF-fixdate only. RFC 850 and asctime are legal in a `Retry-After`
/// but no HTTP/1.1 server has emitted them in decades, and an unrecognised value
/// degrades to "no hint" rather than to a wrong wait. Add them if a real server
/// ever turns up sending one.
fn parse_http_date(value: &str) -> Option<SystemTime> {
    let mut parts = value.split_whitespace();
    let (_weekday, day, month, year, time, zone) = (
        parts.next()?,
        parts.next()?,
        parts.next()?,
        parts.next()?,
        parts.next()?,
        parts.next()?,
    );
    if !zone.eq_ignore_ascii_case("GMT") || parts.next().is_some() {
        return None;
    }

    let day: i64 = day.parse().ok()?;
    let year: i64 = year.parse().ok()?;
    let month = 1 + [
        "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec",
    ]
    .iter()
    .position(|m| *m == month)? as i64;

    let mut hms = time.split(':');
    let (h, m, s): (i64, i64, i64) = (
        hms.next()?.parse().ok()?,
        hms.next()?.parse().ok()?,
        hms.next()?.parse().ok()?,
    );
    if hms.next().is_some() || !(1..=31).contains(&day) || h > 23 || m > 59 || s > 60 {
        return None;
    }

    // days_from_civil: shift the year to start in March so the leap day lands at
    // the end of the cycle, then count era/day-of-era. Hinnant's algorithm.
    let y = if month <= 2 { year - 1 } else { year };
    let era = y.div_euclid(400);
    let yoe = y - era * 400;
    let mp = (month + 9) % 12;
    let doy = (153 * mp + 2) / 5 + day - 1;
    let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
    let days = era * 146_097 + doe - 719_468;

    let secs = days * 86_400 + h * 3_600 + m * 60 + s;
    (secs >= 0).then(|| SystemTime::UNIX_EPOCH + Duration::from_secs(secs as u64))
}

/// The policy target for `url`: its host and port as `host:port`.
///
/// The port is always present, filled from the scheme when the URL omits it, so
/// a rule that names a port has something to match and a rule that does not is
/// still matched by [`Policy::explain`]'s bare-host form. An IPv6 literal keeps
/// its brackets (`[::1]:443`), which is what makes the trailing `:port` split
/// unambiguous.
///
/// Returns `None` when there is no host to check — a malformed URL, or a scheme
/// like `file:` that never opens a connection. A `None` is not permission to
/// proceed: [`NetGuard::check`] treats it as unresolvable and refuses.
pub(crate) fn target(url: &str) -> Option<String> {
    let (scheme, rest) = url.split_once("://")?;
    // Authority ends at the first '/', '?', or '#'.
    let authority = rest
        .split(['/', '?', '#'])
        .next()
        .filter(|a| !a.is_empty())?;
    // Drop any userinfo; credentials are not part of the host.
    let hostport = authority.rsplit_once('@').map_or(authority, |(_, h)| h);

    let default_port = match scheme.to_ascii_lowercase().as_str() {
        "https" | "wss" => "443",
        "http" | "ws" => "80",
        _ => return None,
    };

    if let Some(close) = hostport.strip_prefix('[').and_then(|_| hostport.find(']')) {
        // IPv6 literal: [::1] or [::1]:8080
        let host = &hostport[..=close];
        return match hostport[close + 1..].strip_prefix(':') {
            Some(port) if !port.is_empty() => Some(format!("{host}:{port}")),
            _ => Some(format!("{host}:{default_port}")),
        };
    }

    match hostport.split_once(':') {
        Some((host, port)) if !host.is_empty() && !port.is_empty() => {
            Some(format!("{host}:{port}"))
        }
        Some(_) => None,
        None => Some(format!("{hostport}:{default_port}")),
    }
}

/// The one place an outbound connection is authorized.
///
/// Every check goes through here rather than being repeated at each call site,
/// for the reason the release contract names: a check that is spread across call
/// sites is a policy that *looks* enforced. One guard means one thing to audit
/// and one thing to test.
pub(crate) struct NetGuard<'a> {
    policy: &'a Policy,
    trace: Option<(&'a Store, i64, u32)>,
    /// Where to announce a refusal, and at what tree depth.
    ///
    /// Separate from `trace` because a caller may record without observing, and
    /// because the depth is the agent's rather than the store's.
    watch: Option<(&'a crate::run::Watch<'a>, u32)>,
}

impl<'a> NetGuard<'a> {
    /// Guard connections with `policy`, recording nothing.
    pub(crate) fn new(policy: &'a Policy) -> Self {
        Self {
            policy,
            trace: None,
            watch: None,
        }
    }

    /// Also record every verdict — allow, ask, and refusal alike — against
    /// `run_id` at `step`, so a run's whole network history is reconstructable
    /// from the store afterwards.
    pub(crate) fn tracing(mut self, store: &'a Store, run_id: i64, step: u32) -> Self {
        self.trace = Some((store, run_id, step));
        self
    }

    /// Also announce a network refusal to `watch`.
    ///
    /// Without this a policy-denied host writes a `policy_events` refusal row that
    /// has no `Refused` event beside it — the one place the two surfaces would
    /// have disagreed, which is precisely what the observer's headline test exists
    /// to catch.
    pub(crate) fn watching(mut self, watch: &'a crate::run::Watch<'a>, depth: u32) -> Self {
        self.watch = Some((watch, depth));
        self
    }

    /// Authorize one connection to `url`, returning the verdict for the caller
    /// to act on.
    ///
    /// `Deny` is an [`Error::Refused`] here rather than a returned verdict,
    /// because there is nothing a caller can usefully do with a denial except
    /// not connect — making it the error type removes the option of ignoring it.
    /// `Allow` and `Ask` come back as verdicts; routing `Ask` to a human is the
    /// caller's job, since only the run loop holds the approver.
    pub(crate) fn check(&self, url: &str) -> Result<Verdict> {
        let Some(target) = target(url) else {
            // An unparseable target cannot be checked, and an unchecked
            // connection is exactly what this guard exists to prevent.
            return Err(Error::Refused {
                act: "net".into(),
                target: url.to_string(),
                rule: None,
                layer: None,
            });
        };
        self.check_target(&target)
    }

    /// As [`NetGuard::check`], for a target already in `host:port` form.
    pub(crate) fn check_target(&self, target: &str) -> Result<Verdict> {
        let verdict = self.policy.check(Act::Net, target);
        if let Some((store, run_id, step)) = self.trace {
            let mut ev = match verdict.effect {
                Effect::Allow => PolicyEvent::decision(step, "net", target, "allow", "policy"),
                Effect::Ask => PolicyEvent::decision(step, "net", target, "ask", "policy"),
                Effect::Deny => PolicyEvent::refusal(step, "net", target),
            };
            ev.rule = verdict.rule.clone();
            ev.layer = verdict.layer.clone();
            // Announced from the row itself, so the event cannot carry a rule or
            // layer the row lacks.
            if verdict.effect == Effect::Deny {
                if let Some((watch, depth)) = self.watch {
                    crate::run::refused(watch, run_id, depth, &ev);
                }
            }
            let _ = store.record_event(run_id, &ev);
        }
        if verdict.effect == Effect::Deny {
            return Err(Error::Refused {
                act: "net".into(),
                target: target.to_string(),
                rule: verdict.rule,
                layer: verdict.layer,
            });
        }
        Ok(verdict)
    }
}

/// The layer name under which the harness allows a provider's own endpoint.
///
/// Named rather than exempt: a run under a network-deny base must still reach
/// its model, but an operator reading the trace should see *why* that one host
/// was allowed and which layer said so.
pub(crate) const PROVIDER_LAYER: &str = "provider";

/// A policy layer allowing exactly `target` (`host:port`), for merging beneath a
/// caller's own layers.
///
/// This widens, so it is a [`Policy::merge`] overlay and never a
/// [`Policy::contain`] rule: a caller that explicitly denies its provider host
/// still wins, because deny is absolute across layers. Denying your own provider
/// is a legal configuration; it fails fast as a refusal rather than hanging.
pub(crate) fn provider_layer(target: &str) -> Policy {
    Policy::permissive().layer(PROVIDER_LAYER).allow_net(target)
}

/// An IMF-fixdate for a Unix timestamp — the inverse of [`parse_http_date`], so
/// the tests can say "thirty seconds from now" without a date library.
#[cfg(test)]
pub(crate) fn http_date(unix_secs: u64) -> String {
    let days = (unix_secs / 86_400) as i64;
    let tod = unix_secs % 86_400;
    // civil_from_days, Hinnant again.
    let z = days + 719_468;
    let era = z.div_euclid(146_097);
    let doe = z - era * 146_097;
    let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146_096) / 365;
    let y = yoe + era * 400;
    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
    let mp = (5 * doy + 2) / 153;
    let d = doy - (153 * mp + 2) / 5 + 1;
    let m = if mp < 10 { mp + 3 } else { mp - 9 };
    let year = if m <= 2 { y + 1 } else { y };
    let month = [
        "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec",
    ][(m - 1) as usize];
    // The weekday is not parsed, so any name is accepted on the way back in.
    format!(
        "Mon, {d:02} {month} {year} {:02}:{:02}:{:02} GMT",
        tod / 3600,
        (tod % 3600) / 60,
        tod % 60
    )
}

/// Seconds since the epoch, for building a `Retry-After` date relative to now.
#[cfg(test)]
pub(crate) fn unix_now() -> u64 {
    SystemTime::now()
        .duration_since(SystemTime::UNIX_EPOCH)
        .unwrap()
        .as_secs()
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn retry_after_reads_delta_seconds() {
        assert_eq!(parse_retry_after("7"), Some(Duration::from_secs(7)));
        assert_eq!(parse_retry_after("  0 "), Some(Duration::ZERO));
        assert_eq!(parse_retry_after("120"), Some(Duration::from_secs(120)));
    }

    #[test]
    fn retry_after_reads_an_http_date_as_the_wait_until_then() {
        // A fixed date, checked as an absolute instant so no clock is involved.
        assert_eq!(
            parse_http_date("Sun, 06 Nov 1994 08:49:37 GMT"),
            Some(SystemTime::UNIX_EPOCH + Duration::from_secs(784_111_777))
        );
        // Leap-year and century-boundary arithmetic, where a hand-rolled civil
        // calendar goes wrong if it goes wrong at all.
        assert_eq!(
            parse_http_date("Thu, 01 Jan 1970 00:00:00 GMT"),
            Some(SystemTime::UNIX_EPOCH)
        );
        assert_eq!(
            parse_http_date("Tue, 29 Feb 2000 12:00:00 GMT"),
            Some(SystemTime::UNIX_EPOCH + Duration::from_secs(951_825_600))
        );

        // Relative to now: a date thirty seconds out is a wait of about thirty.
        let waited = parse_retry_after(&http_date(unix_now() + 30)).unwrap();
        assert!(
            waited <= Duration::from_secs(31) && waited >= Duration::from_secs(28),
            "{waited:?}"
        );
    }

    #[test]
    fn a_retry_after_in_the_past_is_a_wait_of_zero() {
        let past = http_date(unix_now() - 600);
        assert_eq!(parse_retry_after(&past), Some(Duration::ZERO));
    }

    #[test]
    fn an_unparseable_retry_after_is_treated_as_absent() {
        for value in [
            "",
            "soon",
            "-5",
            "7.5",
            "Sun, 06 Nov 1994 08:49:37 PST", // not GMT
            "Sun, 06 Nov 1994 08:49 GMT",    // no seconds
            "Sun, 06 Nov 1994 08:49:37",     // no zone
            "Sun, 06 Nov 1994 08:49:37 GMT extra",
            "Sun, 32 Nov 1994 08:49:37 GMT", // no such day
            "Sun, 06 Nov 1994 24:49:37 GMT", // no such hour
            "Sun, 06 Foo 1994 08:49:37 GMT", // no such month
            "Thu, 01 Jan 1969 00:00:00 GMT", // before the epoch
        ] {
            assert_eq!(parse_retry_after(value), None, "{value:?}");
        }
    }

    #[test]
    fn a_retry_after_header_is_read_off_the_response_headers() {
        let mut headers = reqwest::header::HeaderMap::new();
        assert_eq!(retry_after(&headers), None);
        headers.insert("retry-after", "42".parse().unwrap());
        assert_eq!(retry_after(&headers), Some(Duration::from_secs(42)));
    }

    #[test]
    fn a_url_becomes_host_and_port() {
        for (url, want) in [
            (
                "https://api.openai.com/v1/chat/completions",
                "api.openai.com:443",
            ),
            ("http://127.0.0.1:8931/mcp", "127.0.0.1:8931"),
            (
                "https://openrouter.ai/api/v1/chat/completions",
                "openrouter.ai:443",
            ),
            ("http://example.com", "example.com:80"),
            ("https://example.com:8443/x?y=1#z", "example.com:8443"),
            ("https://user:pw@example.com/x", "example.com:443"),
            ("https://[::1]/x", "[::1]:443"),
            ("https://[::1]:8080/x", "[::1]:8080"),
        ] {
            assert_eq!(target(url).as_deref(), Some(want), "{url}");
        }
    }

    #[test]
    fn an_uncheckable_url_is_refused_not_waved_through() {
        for url in [
            "",
            "not a url",
            "file:///etc/passwd",
            "https://",
            "https://host:/x",
        ] {
            assert_eq!(target(url), None, "{url}");
            let p = Policy::permissive();
            // Even a policy that allows everything cannot allow what it cannot see.
            assert!(matches!(
                NetGuard::new(&p).check(url),
                Err(Error::Refused { .. })
            ));
        }
    }

    #[test]
    fn deny_is_an_error_and_allow_is_a_verdict() {
        let p = Policy::default().layer("l").allow_net("api.example.com");
        let guard = NetGuard::new(&p);
        assert_eq!(
            guard.check("https://api.example.com/v1").unwrap().effect,
            Effect::Allow
        );
        assert!(matches!(
            guard.check("https://evil.example.com/v1"),
            Err(Error::Refused { act, .. }) if act == "net"
        ));
    }

    #[test]
    fn the_provider_layer_is_named_and_a_caller_deny_still_wins() {
        let base = Policy::default(); // net default: Deny
        let with_provider = base.merge(provider_layer("api.example.com:443"));
        let v = with_provider.explain(Act::Net, "api.example.com:443");
        assert_eq!(v.effect, Effect::Allow);
        assert_eq!(v.layer.as_deref(), Some(PROVIDER_LAYER));

        let locked = with_provider.layer("caller").deny_net("api.example.com");
        assert_eq!(
            locked.check(Act::Net, "api.example.com:443").effect,
            Effect::Deny
        );
    }
}