Files
ARTEX/intercept/target_resolve.go
T
BaaRai f6b29b835b feat(intercept): pinned dialer for WebFetch and the custom http tool (S3b 2.1)
In a gated scope, the two HTTP clients the platform owns connect only to
addresses that a pin or a stored exemption covers at the moment of the
connection, on every hop.

- intercept.PinnedDialer(tool, scoped): the DialContext of both clients.
  It reads the target scope from the context net/http passes to the dial.
  Gate off, or no scope in a client built without one: plain dial, as
  today. A client built for a scope whose scope is missing at the dial
  fails closed with TARGET_GATE_UNAVAILABLE.
- dialTarget runs the gate decision on the dialled host:port with a fresh
  lookup (block rules first, exemptions, pins, port precedence) and
  connects only to the addresses it compared, never to the name. Derived
  exemptions and name-only pins dial the address as received. An address
  no pin covers finds or opens the request (new_ip / no_pin, wait 0) and
  fails at once with TARGET_APPROVAL_PENDING. Redirect hops are dialled
  against the hop's own (host, port).
- WebFetch (withWebFetch, now given the run context): DialContext set on
  every agent kind; DisableDirectRetry when the run's scope is gated.
- Custom http tool: a direct call in a gated scope gets a DefaultTransport
  clone with the pinned dialer, no environment proxy and keep-alive off.
  Proxied calls are unchanged (the recording proxy is step 2.2).
- Name-only: request reason says the platform does not bind the
  connection; the first-use pin_match row records the effective state.
- Derived exemptions: a proxy URL without a port is listed with the port
  net/http dials (1080 for socks5) (OPEN-9).
- go.mod: Norma v0.4.8 (DialContext, DisableDirectRetry).
2026-11-11 19:19:55 +01:00

185 lines
5.2 KiB
Go

package intercept
import (
"context"
"errors"
"fmt"
"net"
"net/netip"
"slices"
"sync"
"time"
)
// Resolver turns a host name into its addresses. The gate compares what it
// returns with the addresses a human pinned.
type Resolver interface {
// Name identifies the resolver in pins and in audit rows.
Name() string
// LookupIPs returns the A and AAAA answers for host.
LookupIPs(ctx context.Context, host string) ([]netip.Addr, error)
}
// SetResolver replaces the resolver of the target gate, which is the system
// resolver unless one is set.
func (i *Interceptor) SetResolver(r Resolver) { i.resolve.use(r) }
// SetBaseDialer replaces the dialer that opens the connections PinnedDialer
// lets through, which is a net.Dialer with the settings of
// http.DefaultTransport unless one is set. Tests use it to map addresses to
// local listeners and to see what was dialled.
func (i *Interceptor) SetBaseDialer(f func(ctx context.Context, network, addr string) (net.Conn, error)) {
i.mu.Lock()
i.dial = f
i.mu.Unlock()
}
// systemResolver is the resolver of the ARTEX process. It reads the same
// resolver configuration as the tools that run in the same container.
type systemResolver struct{}
func (systemResolver) Name() string { return "system" }
func (systemResolver) LookupIPs(ctx context.Context, host string) ([]netip.Addr, error) {
return net.DefaultResolver.LookupNetIP(ctx, "ip", host)
}
const (
// resolveTimeout bounds one lookup.
resolveTimeout = 3 * time.Second
// resolveCacheTTL is how long a positive answer is reused. It bounds latency
// and resolver load when several calls name one host, and adds at most this
// much to the time a changed address goes unnoticed.
resolveCacheTTL = 10 * time.Second
)
// resolution is one positive answer: canonical addresses, sorted, never empty.
type resolution struct {
ips []string
at time.Time
}
type resolveFlight struct {
done chan struct{}
res resolution
err error
}
// resolveCache resolves names through one Resolver. Positive answers are kept
// per host for resolveCacheTTL, concurrent lookups for one host collapse into
// one, and a failure or an empty answer is never kept: the next call tries
// again.
type resolveCache struct {
now func() time.Time
mu sync.Mutex
resolver Resolver
entries map[string]resolution
flights map[string]*resolveFlight
}
func newResolveCache(r Resolver) *resolveCache {
return &resolveCache{resolver: r, now: time.Now, entries: map[string]resolution{}, flights: map[string]*resolveFlight{}}
}
// use replaces the resolver and drops the answers of the previous one.
func (c *resolveCache) use(r Resolver) {
c.mu.Lock()
c.resolver, c.entries = r, map[string]resolution{}
c.mu.Unlock()
}
func (c *resolveCache) name() string {
c.mu.Lock()
defer c.mu.Unlock()
return c.resolver.Name()
}
// lookup resolves host. fresh skips the cached answer: it is what a new
// approval request uses, because a human reads and pins those addresses.
func (c *resolveCache) lookup(ctx context.Context, host string, fresh bool) (resolution, error) {
c.mu.Lock()
if e, ok := c.entries[host]; ok && !fresh && c.now().Sub(e.at) < resolveCacheTTL {
c.mu.Unlock()
return e, nil
}
f := c.flights[host]
if f == nil {
f = &resolveFlight{done: make(chan struct{})}
c.flights[host] = f
go c.fly(c.resolver, host, f)
}
c.mu.Unlock()
select {
case <-f.done:
return f.res, f.err
case <-ctx.Done():
return resolution{}, ctx.Err()
}
}
// fly runs the one lookup the waiters of host share. It is not bound to the
// context of the call that started it: that call leaving must not fail the others.
func (c *resolveCache) fly(r Resolver, host string, f *resolveFlight) {
ctx, cancel := context.WithTimeout(context.Background(), resolveTimeout)
defer cancel()
addrs, err := r.LookupIPs(ctx, host)
switch {
case ctx.Err() != nil:
f.err = fmt.Errorf("lookup timed out after %s", resolveTimeout)
case err != nil:
f.err = lookupError(err)
case len(addrs) == 0:
f.err = errors.New("the lookup returned no address")
default:
f.res = resolution{ips: canonicalIPs(addrs), at: c.now()}
}
c.mu.Lock()
delete(c.flights, host)
if f.err == nil {
c.entries[host] = f.res
}
c.mu.Unlock()
close(f.done)
}
// lookupError keeps the part of a resolver error that says what went wrong.
func lookupError(err error) error {
var dns *net.DNSError
if errors.As(err, &dns) {
if dns.IsTimeout {
return fmt.Errorf("lookup timed out after %s", resolveTimeout)
}
if dns.IsNotFound {
return errors.New("no such host")
}
return errors.New(dns.Err)
}
return err
}
// canonicalIPs returns the addresses as sorted, unique strings, IPv4-mapped
// addresses unmapped and zones dropped.
func canonicalIPs(addrs []netip.Addr) []string {
out := make([]string, 0, len(addrs))
for _, a := range addrs {
out = append(out, a.Unmap().WithZone("").String())
}
slices.Sort(out)
return slices.Compact(out)
}
// subset reports whether every resolved address is one of the pinned ones. An
// empty answer is never a subset: a lookup that returned nothing matches no pin.
func subset(resolved, pinned []string) bool {
if len(resolved) == 0 {
return false
}
for _, ip := range resolved {
if !slices.Contains(pinned, ip) {
return false
}
}
return true
}