agentplane 0.45.0

Durable, replayable agent runtime — the journal is the plan of record
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
//! What an outbound call may do to this process.
//!
//! Two halves, and they answer different questions about the same call. This
//! module decides **which IP addresses this plane will connect to**;
//! [`intake`] decides **how many bytes an answer may cost**. A counterparty
//! that cannot make this process connect inward can still make it allocate
//! without limit, and neither control substitutes for the other.
//!
//! # Every outbound client, and why the list is not a list
//!
//! The attack is one hostname that resolves inward — to a metadata service, a
//! database, a health endpoint that answers with something interesting — and
//! two settings that quietly hand the connection to somebody else: an ambient
//! proxy, and a redirect this plane follows.
//!
//! It is tempting to scope that to the calls whose URL somebody outside the
//! deployment influenced: governed media fetches one a model was handed, a
//! push notification posts to one a peer supplied, Agent Card discovery
//! fetches one that arrived in a config or a message. That scoping is wrong,
//! and the reason is the third setting. **A configured URL becomes an
//! influenced URL after one redirect.** A model endpoint's own answer chooses
//! the next host; a client that follows it has left the allowlist behind while
//! still carrying whatever header authenticates the request — and `reqwest`
//! strips only `Authorization`, `Cookie` and `Proxy-Authorization` across
//! origins, so `x-api-key`, `x-goog-api-key` and `X-Vault-Token` travel.
//!
//! So the rule is not a count of doors: **every outbound client in this crate
//! is built by one constructor** — `netguard::guarded_client`, crate-private
//! and so named in code font rather than linked. The one exception is
//! [`media`](crate::media), which pins each connection to an address it
//! resolved itself. Both the rule and the exception are held by
//! `tests/guards/layering.rs::every_outbound_client_is_guarded`, because a
//! prose count of the doors is a sentence that is true when written and is
//! not re-checked — and the door it omits is the one standing open.
//!
//! The classification lives here, once, because two implementations of one rule
//! diverge and the one that diverges is whichever nobody probed at the boundary.
//!
//! # Pure, and deliberately not in `core`
//!
//! This module makes no network calls: it answers *is this address one we are
//! willing to reach*, given an address somebody else resolved. Resolution is
//! `resolver`'s, which needs a runtime and a client and is therefore gated on a
//! transport being linked.
//!
//! It still does not live in `core`, which may not reference `std::net` at all —
//! a guard enforces that, and it caught this module on the way in. The rule is
//! blunter than strictly necessary and worth keeping blunt: "no I/O types" is
//! checkable, while "no I/O, except types that only describe addresses" is an
//! argument somebody has to re-make for every exception.
//!
//! # Deny-list, not allow-list, and why that is acceptable here
//!
//! A deny-list of private ranges is normally the weaker construction: anything
//! missed is permitted. It is used here because the alternative — enumerating
//! the public internet — is not expressible, and because it is usually not the
//! only control: media and push both also require an explicit **host** grant,
//! so an address must be both publicly routable *and* named by an operator.
//! This list is meant as the second lock.
//!
//! Card discovery is the exception worth stating rather than glossing: its
//! allowlist is optional, because a deployment that discovers agents from the
//! open internet cannot enumerate them in advance and one that refused to try
//! would simply not be used. With no allowlist set this list is the *only* lock
//! on that path — which is why it is applied there unconditionally, and why a
//! deployment that can name its peers should still name them.

// Gated on every feature that builds an outbound client, which is every
// reqwest-bearing feature: the rule above admits no door that opts out.
#[cfg(any(
    feature = "push",
    feature = "a2a",
    feature = "providers",
    feature = "witness-http",
    feature = "keyring-vault",
))]
mod resolver;
#[cfg(any(
    feature = "push",
    feature = "a2a",
    feature = "providers",
    feature = "witness-http",
    feature = "keyring-vault",
))]
pub(crate) use resolver::{Reach, guarded_client};
// The pre-flight half, which only the two doors carrying a host allowlist ask
// for: `guarded_client` is what the socket obeys, `judge` is what an operator
// gets told before a connection is attempted.
#[cfg(any(feature = "push", feature = "a2a"))]
pub(crate) use resolver::judge;

// The other half of the outbound edge. Gated on the reqwest-bearing features
// rather than on a transport of its own: it reads a `reqwest::Response` and
// every feature listed here is one that holds one.
#[cfg(any(
    feature = "providers",
    feature = "a2a",
    feature = "media",
    feature = "witness-http",
    feature = "keyring-vault",
))]
pub mod intake;

use std::net::{IpAddr, Ipv4Addr, Ipv6Addr};

/// A transport failure as text an operator may read: what went wrong, the host
/// it went wrong at, and the causes beneath it — **never the URL**.
///
/// `reqwest` renders its error with the whole request URL, and a webhook's or a
/// peer's URL routinely carries a bearer secret in its path or query. That text
/// is logged, parked beside a push registration and returned by the operator
/// API, so every error that becomes text on an outbound door goes through here.
#[cfg(any(
    feature = "push",
    feature = "a2a",
    feature = "providers",
    feature = "witness-http",
    feature = "keyring-vault",
    feature = "media",
))]
#[must_use]
pub(crate) fn transport_text(error: &reqwest::Error) -> String {
    let mut text = error.to_string();
    let mut cause = std::error::Error::source(error);
    while let Some(inner) = cause {
        text.push_str(": ");
        text.push_str(&inner.to_string());
        cause = inner.source();
    }
    if let Some(url) = error.url() {
        let host = url.host_str().unwrap_or("?");
        text = text
            .replace(&format!(" for url ({url})"), &format!(" to {host}"))
            .replace(url.as_str(), host);
    }
    text
}

/// The host of a URL, for a log line: the one part of an address that is not a
/// credential.
#[cfg(feature = "push")]
#[must_use]
pub(crate) fn host_of(url: &str) -> String {
    reqwest::Url::parse(url)
        .ok()
        .and_then(|u| u.host_str().map(ToOwned::to_owned))
        .unwrap_or_else(|| "?".to_owned())
}

/// Whether this address is one the plane may connect to.
///
/// False for anything private, local, link-local, multicast, or otherwise
/// special — the ranges an SSRF payload aims at.
#[must_use]
pub fn is_public_ip(ip: IpAddr) -> bool {
    match ip {
        IpAddr::V4(ip) => is_public_v4(ip),
        IpAddr::V6(ip) => is_public_v6(ip),
    }
}

fn is_public_v4(ip: Ipv4Addr) -> bool {
    let [a, b, c, _] = ip.octets();
    !(a == 0
        || a == 10
        || a == 127
        || (a == 100 && (64..=127).contains(&b))
        || (a == 169 && b == 254)
        || (a == 172 && (16..=31).contains(&b))
        || (a == 192 && b == 0 && c == 0)
        || (a == 192 && b == 0 && c == 2)
        || (a == 192 && b == 88 && c == 99)
        || (a == 192 && b == 168)
        || (a == 198 && (b == 18 || b == 19))
        || (a == 198 && b == 51 && c == 100)
        || (a == 203 && b == 0 && c == 113)
        || a >= 224)
}

fn is_public_v6(ip: Ipv6Addr) -> bool {
    let segments = ip.segments();
    // An IPv4-mapped address is an IPv4 address wearing a different notation, so
    // it is judged as one. Skipping this is how `::ffff:127.0.0.1` reaches
    // loopback through a v6 check that looked thorough.
    if let Some(mapped) = ip.to_ipv4_mapped() {
        return is_public_v4(mapped);
    }
    // The first two clauses are deliberately redundant: `::` and `::1` both
    // have `segments[0] == 0`, so the clause below already refuses them. They
    // stay because a reader should not have to derive "loopback is refused"
    // from a bitmask, and because the arithmetic rule could later be narrowed.
    //
    // The consequence is worth stating so it is not re-investigated: an
    // automated sweep reports `|| -> &&` here as surviving, and it always will.
    // Nothing can distinguish the two, since every address satisfying either
    // clause satisfies the one below. It is an equivalent mutant, not a gap —
    // the only one left in this file, and the rest of it is pinned from both
    // sides of every edge by `every_range_is_refused_and_its_neighbour_is_not`.
    !(ip.is_unspecified()
        || ip.is_loopback()
        || (segments[0] & 0xfe00) == 0xfc00
        || (segments[0] & 0xffc0) == 0xfe80
        || (segments[0] & 0xffc0) == 0xfec0
        || (segments[0] & 0xff00) == 0xff00
        || segments[0] == 0
        || (segments[0] == 0x0064 && segments[1] == 0xff9b)
        || (segments[0] == 0x0100 && segments[1] == 0)
        || (segments[0] == 0x2001 && segments[1] <= 0x01ff)
        || (segments[0] == 0x2001 && segments[1] == 0x0db8)
        || segments[0] == 0x2002
        || (segments[0] & 0xfff0) == 0x3ff0
        || segments[0] == 0x5f00)
}

/// Whether a host *names* this machine, without resolving it.
///
/// Literals only, plus the one name every stack special-cases. Anything that
/// merely **resolves** to loopback is deliberately not covered: that is
/// [`all_public`]'s job against the answers DNS actually gave, and a name-based
/// guess in front of it would be a second implementation of one rule.
///
/// The distinction is the whole security content of this function. A loopback
/// exception keyed on the *name* is one a deployment wrote down; one keyed on
/// the *resolution* is one an attacker arranges, because making a name resolve
/// inward is the rebinding attack itself.
#[must_use]
pub fn is_loopback_name(host: &str) -> bool {
    if host == "localhost" {
        return true;
    }
    let bare = host
        .strip_prefix('[')
        .and_then(|h| h.strip_suffix(']'))
        .unwrap_or(host);
    bare.parse::<IpAddr>().is_ok_and(|ip| ip.is_loopback())
}

/// A host grant, canonicalised the way a fetched or posted URL's host will be.
///
/// Both callers that grant a host — governed media and push webhooks — compare
/// the grant against [`reqwest::Url::host_str`], which the URL crate has
/// already lowercased and IDNA-encoded to punycode. A grant that was only
/// lowercased would store an internationalised host in its Unicode form and
/// **never match** the `xn--…` a URL to that host carries — fail-closed, but
/// silently, refusing every request to the host it was meant to permit. Both
/// grants therefore route through this, so the two sides agree by construction.
///
/// One implementation, in the module both callers already share for the
/// address rule, because the copy that diverges is whichever nobody probed at
/// the boundary.
///
/// `None` for anything that is not a bare host a URL could name: a grant
/// carrying a port, userinfo, a path, a query or a fragment is not the exact
/// host it appears to be, and is refused rather than stored as something that
/// cannot match.
#[cfg(any(feature = "media", feature = "push"))]
#[must_use]
pub fn canonical_host(raw: &str) -> Option<String> {
    let url = reqwest::Url::parse(&format!("https://{}/", raw.trim())).ok()?;
    if !url.username().is_empty()
        || url.password().is_some()
        || url.port().is_some()
        || url.path() != "/"
        || url.query().is_some()
        || url.fragment().is_some()
    {
        return None;
    }
    Some(url.host_str()?.trim_end_matches('.').to_ascii_lowercase())
}

/// Why a resolution was rejected.
///
/// Two variants rather than one string, because the caller's response differs by
/// kind: an empty resolution is an outage (retry later), a forbidden address is
/// a refusal (never fetch this). Deciding that by matching on message text — as
/// `media` once did — means the first reword of a message here silently flips a
/// retry into an abandonment. The distinction is a type.
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum NetGuardError {
    /// The host resolved to no addresses at all. An outage, not a refusal.
    #[error("DNS for '{host}' returned no addresses")]
    NoAddresses { host: String },
    /// The host resolved to a non-public address — loopback, private,
    /// link-local, or the cloud metadata service. The SSRF case.
    #[error("'{host}' resolved to forbidden address {address}")]
    Forbidden { host: String, address: IpAddr },
}

/// Refuse unless **every** resolved address is public, and return the set to
/// connect to.
///
/// All of them, not the first: a hostname that answers with one public address
/// and one private one is the standard rebinding setup, and a check that stops
/// at the first answer passes it.
///
/// The returned addresses are the ones to dial. Resolving the name again would
/// invite a different answer, which is the rebinding this refuses — so the
/// judgement and the connection must be about the same bytes.
///
/// # Errors
///
/// [`NetGuardError::NoAddresses`] when there were no answers at all, and
/// [`NetGuardError::Forbidden`] when any answer is not public.
pub fn all_public<I>(host: &str, addresses: I) -> Result<Vec<std::net::SocketAddr>, NetGuardError>
where
    I: IntoIterator<Item = std::net::SocketAddr>,
{
    let addresses: Vec<_> = addresses.into_iter().collect();
    if addresses.is_empty() {
        return Err(NetGuardError::NoAddresses {
            host: host.to_owned(),
        });
    }
    for address in &addresses {
        if !is_public_ip(address.ip()) {
            return Err(NetGuardError::Forbidden {
                host: host.to_owned(),
                address: address.ip(),
            });
        }
    }
    let mut unique = std::collections::BTreeSet::new();
    unique.extend(addresses);
    Ok(unique.into_iter().collect())
}

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

    /// Every range, from both sides of every edge.
    ///
    /// A list of addresses that must be refused proves less than it looks. It
    /// cannot fail when a bound moves *outward*, because a wider rule still
    /// refuses everything the list names — so `b == 254` becoming `b != 254`,
    /// or `<=` becoming `>`, leaves it green while the classifier has changed
    /// meaning. An automated sweep put a number on that: of 111 mutations to
    /// this file, **67 survived** — nearly every comparison and nearly every
    /// `||` between the ranges, in the one function standing between a hostile
    /// URL and the internal network.
    ///
    /// So each row carries an address *inside* the range and its nearest
    /// neighbour *outside*, and the outside half is what does the work: it is
    /// the only thing that fails when a rule grows. The neighbours are chosen
    /// to be caught by no other rule, or they would prove nothing either.
    ///
    /// This pins **this classifier's** boundaries, not IANA's registry. Where
    /// the two differ the code is the subject under test, and a deliberate
    /// widening should fail here and be re-approved rather than pass quietly.
    /// (refused address, permitted neighbour, what the pair pins)
    type Edge = (&'static str, &'static str, &'static str);

    /// Both halves of every row, with the message naming which half failed.
    fn assert_edges(edges: &[Edge]) {
        for &(refused, permitted, why) in edges {
            assert!(
                !is_public_ip(refused.parse().unwrap()),
                "{refused} was treated as publicly routable ({why})"
            );
            assert!(
                is_public_ip(permitted.parse().unwrap()),
                "{permitted} was refused, so the rule for {why} reaches further \
                 than it should — the guard is refusing part of the internet"
            );
        }
    }

    #[test]
    fn every_ipv4_range_is_refused_and_its_neighbour_is_not() {
        assert_edges(&[
            ("0.1.2.3", "1.0.0.1", "0.0.0.0/8 — 'this network'"),
            ("10.255.255.254", "11.0.0.1", "10/8 private"),
            ("127.255.255.254", "128.0.0.1", "127/8 loopback"),
            (
                "100.64.0.1",
                "100.63.255.254",
                "100.64/10 CGNAT, lower edge",
            ),
            (
                "100.127.255.254",
                "100.128.0.1",
                "100.64/10 CGNAT, upper edge",
            ),
            (
                "169.254.169.254",
                "169.253.0.1",
                "link-local — cloud metadata",
            ),
            ("169.254.0.1", "169.255.0.1", "link-local, upper edge"),
            ("172.16.0.1", "172.15.0.1", "172.16/12 private, lower edge"),
            (
                "172.31.255.254",
                "172.32.0.1",
                "172.16/12 private, upper edge",
            ),
            (
                "192.0.0.1",
                "192.0.1.1",
                "192.0.0/24 IETF protocol assignments",
            ),
            ("192.0.2.1", "192.0.3.1", "192.0.2/24 TEST-NET-1"),
            (
                "192.88.99.1",
                "192.88.100.1",
                "192.88.99/24 6to4 relay anycast",
            ),
            (
                "192.168.1.1",
                "192.167.0.1",
                "192.168/16 private, lower edge",
            ),
            (
                "192.168.255.254",
                "192.169.0.1",
                "192.168/16 private, upper edge",
            ),
            (
                "198.18.0.1",
                "198.17.0.1",
                "198.18/15 benchmarking, lower edge",
            ),
            (
                "198.19.255.254",
                "198.20.0.1",
                "198.18/15 benchmarking, upper edge",
            ),
            ("198.51.100.1", "198.51.101.1", "198.51.100/24 TEST-NET-2"),
            ("203.0.113.1", "203.0.114.1", "203.0.113/24 TEST-NET-3"),
            ("224.0.0.1", "223.255.255.254", "224/4 multicast and above"),
            ("255.255.255.255", "223.255.255.254", "broadcast"),
        ]);
    }

    /// The v6 half of [`every_ipv4_range_is_refused_and_its_neighbour_is_not`].
    ///
    /// Split by family only to keep each function readable; the reasoning in
    /// that test's documentation governs both.
    #[test]
    fn every_ipv6_range_is_refused_and_its_neighbour_is_not() {
        assert_edges(&[
            ("::", "1::1", "unspecified, and ::/16 generally"),
            ("::2", "1::1", "::/16 — includes IPv4-compatible v6"),
            ("::1", "1::1", "loopback"),
            // An IPv4 address in v6 notation still reaches what it names, and
            // the neighbour proves the mapping is judged rather than waved past.
            (
                "::ffff:127.0.0.1",
                "::ffff:1.1.1.1",
                "IPv4-mapped is judged as v4",
            ),
            ("64:ff9b::1", "64:ff9c::1", "64:ff9b::/32 NAT64 well-known"),
            ("64:ff9b:1::1", "65::1", "64:ff9b:1::/48 local-use NAT64"),
            ("100::1", "101::1", "100::/64 discard-only"),
            (
                "2001::1",
                "2001:200::1",
                "2001::/23 protocol assignments, lower",
            ),
            ("2001:1ff::1", "2001:200::1", "2001::/23 upper edge"),
            ("2001:db8::1", "2001:db9::1", "2001:db8::/32 documentation"),
            ("2002::1", "2003::1", "2002::/16 6to4"),
            ("3ffe::1", "3fe0::1", "3ff0::/12, lower edge"),
            ("3fff::1", "3fe0::1", "3ff0::/12, upper edge"),
            ("5f00::1", "5f01::1", "5f00::/16 segment routing"),
            ("fc00::1", "fe00::1", "fc00::/7 unique-local, lower edge"),
            ("fdff::1", "fe00::1", "fc00::/7 unique-local, upper edge"),
            ("fe80::1", "fe40::1", "fe80::/10 link-local, lower edge"),
            ("febf::1", "fe40::1", "fe80::/10 link-local, upper edge"),
            ("fec0::1", "fe00::1", "fec0::/10 site-local, lower edge"),
            ("feff::1", "fe00::1", "fec0::/10 site-local, upper edge"),
            ("ff02::1", "fe00::1", "ff00::/8 multicast"),
        ]);
    }

    #[test]
    fn ordinary_public_addresses_are_permitted() {
        for addr in ["1.1.1.1", "93.184.216.34", "2606:4700:4700::1111"] {
            assert!(
                is_public_ip(addr.parse().unwrap()),
                "{addr} was refused, so the guard refuses the internet"
            );
        }
    }

    /// One private answer poisons the whole resolution.
    ///
    /// The rebinding setup: a name answers with one public address and one
    /// private one, and a check that stops at the first passes it.
    #[test]
    fn one_private_answer_refuses_the_whole_resolution() {
        let addrs = [
            "1.1.1.1:443".parse().unwrap(),
            "127.0.0.1:443".parse().unwrap(),
        ];
        assert!(
            all_public("rebind.example", addrs).is_err(),
            "a resolution containing a private address was accepted because \
             another answer was public"
        );
    }
}