agentplane 0.53.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
//! The address rule applied at connect time, so a client can be reused.
//!
//! # Why this exists rather than pinning each request
//!
//! Pinning a connection to pre-approved addresses is correct and costs a whole
//! HTTP client per request: a pin is a property of a [`reqwest::Client`], so a
//! caller that pins per destination builds one per destination — a fresh pool,
//! TLS session and handshake for every call. Judging in the resolver keeps the
//! guarantee and drops the cost, and covers more: a pooled client opens
//! connections long after any pre-flight returned, and those resolve through
//! here too.
//!
//! # What this covers, and what the caller still owes
//!
//! **Names only.** A host that is already an IP literal never reaches any
//! resolver — the connector dials it directly — so nothing here sees it. That
//! is not a gap, because a literal cannot resolve to something else later:
//! there is exactly one address, the caller's pre-flight already judged it, and
//! it is the one dialled. Names are the case with a second answer in it, and
//! they are the case this exists for.
//!
//! So the division is: the caller's pre-flight judges **every** destination,
//! once, and is what refuses a forbidden one. This re-judges **every name
//! resolution**, which is what closes the window between that check and a
//! connection the pool opens later. Neither is sufficient alone, and both call
//! one rule — [`judge`] — so they cannot disagree.
//!
//! It is also not a *classifier*: a refused address and a name that does not
//! resolve both reach the caller as a connect error, because that is all a DNS
//! hook can return. Telling an operator which happened is the pre-flight's job.
//!
//! Building a client that uses this is [`guarded_client`], which is the only
//! supported way: the resolver alone is not the guarantee.

use std::net::SocketAddr;
use std::sync::Arc;

/// How far a client is permitted to reach.
///
/// Each variant is gated on the features that construct it, so the compiler
/// answers *which reaches exist in this build* rather than a lint being
/// silenced to let a variant outlive its caller.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum Reach {
    /// Publicly routable addresses only — the rule for anything whose URL was
    /// influenced by somebody outside the deployment.
    #[cfg(any(feature = "push", feature = "a2a"))]
    Public,
    /// Public, plus a host that **is** a loopback literal or `localhost`.
    ///
    /// Named rather than inferred: the exception covers a host that *is*
    /// loopback, never one that merely resolved there, which is the rebinding
    /// attack and stays refused. Reachable only from `testkit` callers.
    #[cfg(any(feature = "push", feature = "a2a"))]
    PublicOrLoopbackName,
    /// Whatever the resolver answers.
    ///
    /// For destinations the **deployment itself** configured, where resolving
    /// inward is the point: an in-cluster collector has no public address, an
    /// on-premise model gateway and a Vault cluster usually have none either,
    /// and refusing them leaves an operator running a sidecar that terminates
    /// TLS and forwards in clear — the same exposure with an extra hop.
    ///
    /// What this reach does **not** waive is the other two settings, and they
    /// are the ones these doors were missing: a proxy taken from the ambient
    /// environment, and a redirect that moves the request to a host no
    /// allowlist granted.
    #[cfg(any(
        feature = "push",
        feature = "providers",
        feature = "witness-http",
        feature = "keyring-vault",
        feature = "a2a",
    ))]
    Configured,
}

/// Judge a set of resolved addresses against a reach.
///
/// The **one** implementation of "may this client connect to these", called
/// from two places on purpose: here, at connect time, where it is what the
/// socket obeys; and from a caller's pre-flight, where it is what an operator
/// gets told. A second spelling of it would agree everywhere except the
/// boundary nobody probed.
///
/// # Errors
///
/// [`super::NetGuardError`] — `NoAddresses` for an outage, `Forbidden` for the
/// SSRF case. The distinction is a type because the caller's response differs:
/// one is retried, one never is.
pub(crate) fn judge<I>(
    reach: Reach,
    host: &str,
    resolved: I,
) -> Result<Vec<SocketAddr>, super::NetGuardError>
where
    I: IntoIterator<Item = SocketAddr>,
{
    let addrs: Vec<SocketAddr> = resolved.into_iter().collect();
    let exempt = match reach {
        #[cfg(any(
            feature = "push",
            feature = "providers",
            feature = "witness-http",
            feature = "keyring-vault",
            feature = "a2a",
        ))]
        Reach::Configured => true,
        // Named rather than inferred: the exception covers a host that *is*
        // loopback, never one that merely resolved there.
        #[cfg(any(feature = "push", feature = "a2a"))]
        Reach::PublicOrLoopbackName => super::is_loopback_name(host),
        #[cfg(any(feature = "push", feature = "a2a"))]
        Reach::Public => false,
    };
    if !exempt {
        return super::all_public(host, addrs);
    }
    // An exemption is from the *address* rule, never from the one answer that
    // is not a judgement: nothing to connect to is an outage, and a caller that
    // could not tell it from a refusal would abandon a destination over a DNS
    // blip.
    if addrs.is_empty() {
        return Err(super::NetGuardError::NoAddresses {
            host: host.to_owned(),
        });
    }
    Ok(addrs)
}

/// A [`reqwest::dns::Resolve`] that applies [`judge`].
#[derive(Debug, Clone, Copy)]
pub(crate) struct GuardedResolver {
    reach: Reach,
}

impl GuardedResolver {
    pub(crate) const fn new(reach: Reach) -> Self {
        Self { reach }
    }

    /// A shared handle, which is what [`reqwest::ClientBuilder::dns_resolver`]
    /// takes.
    pub(crate) fn shared(reach: Reach) -> Arc<Self> {
        Arc::new(Self::new(reach))
    }
}

/// A client builder that already carries every rule an outbound client here
/// needs.
///
/// One constructor rather than three settings each caller remembers, because
/// the three are not independent: a proxy makes the resolver unreachable, a
/// redirect makes the judged host the wrong host, and the resolver is what
/// judges at all. A caller that set two of the three would have a client that
/// looks guarded and is not, and nothing downstream could observe the
/// difference — the address it must not reach is one no test can make DNS
/// answer with on demand.
///
/// So the guarantee is structural: there is one way to build a guarded client
/// in this crate, and it is this. What each setting buys:
///
/// * **`dns_resolver`** — every *name* this client resolves is judged,
///   including on the connections a pool opens long after any caller's
///   pre-flight returned. An IP-literal host bypasses it, which costs nothing:
///   a literal has one address and cannot rebind. Deliberately not combined
///   with `resolve`/`resolve_to_addrs`, which reqwest applies *over* a custom
///   resolver rather than through it.
/// * **`no_proxy`** — a proxied request resolves the *proxy*, so the resolver
///   would never be asked about the destination host at all. It also keeps this
///   plane's ambient identity off a request it did not authorize.
/// * **`redirect::none`** — a judgement about the endpoint says nothing about
///   the third host it forwards to.
pub(crate) fn guarded_client(reach: Reach) -> reqwest::ClientBuilder {
    reqwest::Client::builder()
        .no_proxy()
        .redirect(reqwest::redirect::Policy::none())
        .dns_resolver(GuardedResolver::shared(reach))
}

impl reqwest::dns::Resolve for GuardedResolver {
    fn resolve(&self, name: reqwest::dns::Name) -> reqwest::dns::Resolving {
        let reach = self.reach;
        Box::pin(async move {
            let host = name.as_str().to_owned();
            // Port zero: reqwest replaces it with the URL's port, or the
            // scheme's default when the URL names none. Resolving against a
            // port of our own choosing would be a second place the port is
            // decided, and the one that drifts is whichever the tests do not
            // exercise.
            let resolved = tokio::net::lookup_host((host.as_str(), 0)).await?;
            let addrs = judge(reach, &host, resolved)
                .map_err(|refusal| Box::new(refusal) as Box<dyn std::error::Error + Send + Sync>)?;
            Ok(Box::new(addrs.into_iter()) as reqwest::dns::Addrs)
        })
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use reqwest::dns::Resolve as _;

    /// The resolver refuses what the pre-flight would have refused.
    ///
    /// Tested here because this is the check a **pooled** connection obeys, and
    /// it is the one a caller's pre-flight cannot stand in for: the pool opens
    /// connections after that check returned, re-resolving as it does. A
    /// resolver that answered with the addresses anyway would leave every
    /// caller's refusal a formality that holds only until the first reconnect.
    #[cfg(any(feature = "push", feature = "a2a"))]
    #[tokio::test]
    async fn a_public_only_client_is_not_handed_a_loopback_address() {
        let name: reqwest::dns::Name = "localhost".parse().expect("a resolvable name");
        let refused = GuardedResolver::new(Reach::Public).resolve(name).await;
        let error = refused.err().expect("localhost resolves inward");
        assert!(
            error.to_string().contains("forbidden address"),
            "a public-only client was handed loopback: {error}"
        );
    }

    /// A client built here cannot reach a server that is genuinely listening
    /// on this machine.
    ///
    /// The one test that observes the **wiring** rather than the rule. Every
    /// caller also checks its destination before dispatching, so a refusal in a
    /// caller's test proves only that the pre-flight ran — a client with no
    /// resolver attached passes all of those unchanged, and the address it
    /// would then reach is one no test can make DNS answer with on demand.
    ///
    /// Here there is no pre-flight: the request goes straight out. A real
    /// listener is bound so that the default resolver would *succeed*, which is
    /// what makes the failure meaningful rather than a name that resolves to
    /// nothing.
    #[cfg(any(feature = "push", feature = "a2a"))]
    #[tokio::test]
    async fn a_guarded_client_does_not_reach_a_live_server_on_this_machine() {
        let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
            .await
            .expect("a local port");
        let port = listener.local_addr().expect("an address").port();
        let served = tokio::spawn(async move {
            if let Ok((mut socket, _)) = listener.accept().await {
                use tokio::io::AsyncWriteExt as _;
                let _ = socket
                    .write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nhi")
                    .await;
                return true;
            }
            false
        });

        let client = guarded_client(Reach::Public)
            .timeout(std::time::Duration::from_secs(2))
            .build()
            .expect("a client");
        let outcome = client.get(format!("http://localhost:{port}/")).send().await;
        // The refusal must be the resolver's. Any error would also pass on a
        // runner where the listener's close resets the connection, and then a
        // client with no address rule reads as one that has it.
        let error = outcome.expect_err(
            "a public-only client reached a server on this machine, so the \
             address rule is not attached to the client and every pre-flight \
             that passes it is the only check there is",
        );
        let mut chain = error.to_string();
        let mut cause: &dyn std::error::Error = &error;
        while let Some(next) = cause.source() {
            chain.push_str(": ");
            chain.push_str(&next.to_string());
            cause = next;
        }
        assert!(
            chain.contains("forbidden address"),
            "the request failed, but not because the address rule refused it: {chain}"
        );

        served.abort();
    }

    /// An IP-literal host is judged by the caller's pre-flight, never here.
    ///
    /// The connector dials a literal directly, so no resolver is consulted —
    /// which is why [`guarded_client`] is never the whole control and every
    /// caller checks its destination first. Pinned because the division of
    /// labour is invisible from either side alone: this asserts the half that
    /// is *not* covered here, so a later reading of "the client judges every
    /// connection" cannot quietly become true-sounding.
    #[tokio::test]
    async fn an_ip_literal_never_reaches_this_resolver() {
        use std::sync::atomic::{AtomicUsize, Ordering};

        static CALLS: AtomicUsize = AtomicUsize::new(0);

        #[derive(Debug)]
        struct Counting;
        impl reqwest::dns::Resolve for Counting {
            fn resolve(&self, name: reqwest::dns::Name) -> reqwest::dns::Resolving {
                CALLS.fetch_add(1, Ordering::SeqCst);
                Box::pin(async move {
                    let addrs = tokio::net::lookup_host((name.as_str(), 0)).await?;
                    Ok(Box::new(addrs.collect::<Vec<_>>().into_iter()) as reqwest::dns::Addrs)
                })
            }
        }

        let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
            .await
            .expect("a local port");
        let port = listener.local_addr().expect("an address").port();
        let served = tokio::spawn(async move {
            if let Ok((mut socket, _)) = listener.accept().await {
                use tokio::io::AsyncWriteExt as _;
                let _ = socket
                    .write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nhi")
                    .await;
            }
        });

        let client = reqwest::Client::builder()
            .dns_resolver(Arc::new(Counting))
            .timeout(std::time::Duration::from_secs(2))
            .build()
            .expect("a client");
        let _ = client.get(format!("http://127.0.0.1:{port}/")).send().await;
        assert_eq!(
            CALLS.load(Ordering::SeqCst),
            0,
            "a literal reached the resolver, so the split this module documents \
             — pre-flight judges every destination, this judges every name — is \
             not the split the transport implements"
        );

        served.abort();
    }

    /// Whichever reaches this build has, the exempt ones are exempt.
    ///
    /// A `const fn` per variant rather than a conditional `vec!` literal, so a
    /// build's list is assembled from calls that each return nothing when the
    /// variant does not exist — the pattern this crate prefers over a `mut`
    /// binding whose mutability depends on a feature.
    #[tokio::test]
    async fn the_exemptions_reach_what_they_are_for_and_nothing_else() {
        fn loopback_name() -> Vec<Reach> {
            #[cfg(any(feature = "push", feature = "a2a"))]
            return vec![Reach::PublicOrLoopbackName];
            #[cfg(not(any(feature = "push", feature = "a2a")))]
            return Vec::new();
        }
        fn configured() -> Vec<Reach> {
            #[cfg(any(
                feature = "push",
                feature = "providers",
                feature = "witness-http",
                feature = "keyring-vault",
                feature = "a2a",
            ))]
            return vec![Reach::Configured];
            #[cfg(not(any(
                feature = "push",
                feature = "providers",
                feature = "witness-http",
                feature = "keyring-vault",
                feature = "a2a",
            )))]
            return Vec::new();
        }
        let exempt: Vec<Reach> = loopback_name().into_iter().chain(configured()).collect();
        assert!(
            !exempt.is_empty(),
            "this build compiled the resolver and has no reach at all, so nothing \
             here is under test"
        );
        for reach in exempt {
            let name: reqwest::dns::Name = "localhost".parse().expect("a resolvable name");
            let addrs = GuardedResolver::new(reach)
                .resolve(name)
                .await
                .unwrap_or_else(|error| panic!("{reach:?} refused localhost: {error}"));
            assert!(
                addrs.count() > 0,
                "{reach:?} answered with nothing, which is an outage rather than \
                 the exemption it is supposed to be"
            );
        }
    }

    /// The loopback exemption is keyed on the **name**, never on the answer.
    ///
    /// A host that merely resolves inward is the rebinding attack, and it stays
    /// refused with the exception in force — otherwise the exemption is not an
    /// exception, it is an off switch anybody can reach by controlling a DNS
    /// record.
    #[cfg(any(feature = "push", feature = "a2a"))]
    #[test]
    fn a_name_that_is_not_loopback_gets_no_exemption_from_its_answers() {
        let inward = "10.0.0.1:443".parse().expect("an address");
        let refused = judge(Reach::PublicOrLoopbackName, "peer.example", vec![inward]);
        assert!(
            matches!(refused, Err(super::super::NetGuardError::Forbidden { .. })),
            "a public name resolving to a private address was admitted under the \
             loopback exception: {refused:?}"
        );
    }
}