mirror of
https://github.com/BaaRai/ARTEX.git
synced 2026-11-15 04:30:55 +08:00
The approval gate for asset targets now decides. It is opt-in per task and per conversation (target_gate, default off). With the flag off nothing is extracted, resolved, read or written, and every branch of the hook behaves as before. No UI. Extraction and normalisation (1.5) - intercept.NormalizeTarget: lower case, trailing dot, userinfo, IPv6 brackets, IPv4-mapped IPv6, decimal / hex / octal / short IPv4, percent- encoded host, punycode; port explicit, else the scheme default, else 0. - intercept.ExtractTargets: WebFetch url exactly; Bash, shell_open, Monitor (command) and shell_send (text) with the command-string rules (URL with a scheme, IP, IP:port, host:port, CIDR; a bare name is not a target); every string of the input for MCP tools, custom tools and any tool the gate does not know. ExecuteExtraTool is unwrapped to the inner tool. The platform's domain tools, the file tools and web_search have no target. - The fields are decoded as the tools decode them (key case, last duplicate), and a URL authority that reads differently quoted and unquoted yields both readings. Resolver, exemptions, gate decision (1.6) - Resolver interface, system implementation (A and AAAA), 3 s per lookup, positive answers cached 10 s per host with collapsed lookups, failures and empty answers never cached, always fresh when a request is raised. - Exemptions are exact host + port. Derived from the configuration (LLM gateways, search backend in use, platform proxies): computed on every call, never resolved, never voided. Stored (settings key target_gate_exempt_hosts, seeded once with the package sources): resolved, and void when any address is private, loopback, link-local, unspecified, unique-local, multicast or a metadata address. An entry in both origins counts as derived. A stored IP literal that can never be exempt does not apply. - GET / PUT /api/target-gate/exemptions and GET .../history. A change is validated (ValidateExemptionHost on the normalised host) and appended to target_gate_exemption_log in the transaction that stores it. - Interceptor.CheckTargets: asset block rules (global and of the task) on the name and its addresses, exemptions, resolution, pin lookup. A (host, 0) pin covers every port; a (host, 443) pin covers 443 only and never a target without a declared port; two pins are never merged. At most 5 unpinned targets per call. Codes: TARGET_RULE_BLOCKED, TARGET_RESOLVE_FAILED, TARGET_TOO_MANY, TARGET_GATE_UNAVAILABLE; none opens a request. - With the upstream egress proxy set every pin is name-only at call time, whatever name_only_effective says in its row. - An approval never pins zero addresses: the pin writer refuses a request that displays none (db.ErrTargetPinEmpty, 422 on the decide endpoint, the row stays pending) and RequestTargets does not open one. - Every block, and the first use per process of each exemption and of each pin version, is a decided row (decided_by system, decision_source target). The gate in the hook (1.7) - guard.applyIntercept: a deny by a rule or by the judge blocks first, before any target request exists; then CheckTargets; an allow is logged as before and does not satisfy the gate; an ask and an unpinned target share one request, and the classic ask is raised only when it was not merged. The call is checked again after the decisions, at most three passes. - The gate applies to every tool of a gated session, enabled for rules or not, and to the target-only guard of the planner and the main agent, which still records blocks only. - Target blocks have their own model-facing text, built from the code and the reason of the result. Not in this change: rendered targets of custom tools (1.8) and redirect targets (1.9). Subprocess paths stay advisory; the TestBypass_* tests document what the gate does not see.
175 lines
4.8 KiB
Go
175 lines
4.8 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) }
|
|
|
|
// 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
|
|
}
|