Files
ARTEX/intercept/target_exempt.go
BaaRai 78f92f44fc feat(intercept): target extraction, resolver, exemptions and the gate in the hook (S3b 1.5-1.7)
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.
2026-11-11 00:34:55 +01:00

173 lines
6.0 KiB
Go

package intercept
import (
"encoding/json"
"errors"
"fmt"
"net/netip"
"strings"
"github.com/Autumn-27/artex/db"
)
// metadataAddrs are instance-metadata addresses that lie outside the link-local
// ranges and would otherwise pass as private or public unicast.
var metadataAddrs = []netip.Addr{
netip.MustParseAddr("fd00:ec2::254"), // AWS IMDS over IPv6
netip.MustParseAddr("100.100.100.200"), // Alibaba Cloud
}
// ValidateExemptionHost checks the host of an entry an operator adds to the
// target-gate exemption list. host is expected in normalised form.
//
// An exemption on a name is resolved at check time and is void when the name
// resolves to a private, loopback, link-local or unspecified address. An IP
// literal is never resolved, so that rule cannot protect it and the literal is
// checked here instead. A private unicast address is allowed: an internal
// service the operator names on purpose (10.0.0.5:8080). Loopback, link-local
// (169.254.0.0/16 holds the cloud metadata address 169.254.169.254; fe80::/10),
// unspecified and multicast addresses are never accepted, nor are the metadata
// addresses outside the link-local ranges. A host that is not an IP literal
// passes: names are the void rule's business.
func ValidateExemptionHost(host string) error {
addr, err := netip.ParseAddr(host)
if err != nil {
return nil
}
addr = addr.Unmap().WithZone("")
kind := ""
switch {
case addr.IsLoopback():
kind = "a loopback"
case addr.IsUnspecified():
kind = "the unspecified"
case addr.IsLinkLocalUnicast():
kind = "a link-local (metadata range)"
case addr.IsMulticast():
kind = "a multicast"
}
for _, m := range metadataAddrs {
if addr == m {
kind = "an instance-metadata"
}
}
if kind != "" {
return fmt.Errorf("%s is %s address and can never be exempt from target approval", host, kind)
}
return nil
}
// ErrInvalidExemption is wrapped by the reason an exemption entry is refused.
var ErrInvalidExemption = errors.New("invalid exemption")
// SetDerivedExemptions installs the function that lists the endpoints of the
// platform's own services, as URLs or host:port: the LLM gateways, the search
// backend in use and the platform proxies. They are exempt because the
// configuration names them. They are computed at check time and never stored,
// so they follow the configuration and cannot be set through the exemption
// list. A derived exemption is never resolved and never voided: an internal
// relay for a platform service is a normal deployment.
func (i *Interceptor) SetDerivedExemptions(f func() []string) {
i.mu.Lock()
i.derivedExempt = f
i.mu.Unlock()
}
// DerivedExemptions returns the exemptions derived from the configuration, as
// exact host and port. An endpoint without a port is not listed.
func (i *Interceptor) DerivedExemptions() []db.TargetExemption {
i.mu.RLock()
f := i.derivedExempt
i.mu.RUnlock()
out := []db.TargetExemption{}
if f == nil {
return out
}
seen := map[string]bool{}
for _, raw := range f() {
t, ok := NormalizeTarget(raw)
if !ok || t.Port == 0 || t.Kind == TargetCIDR || seen[db.TargetKey(t.Host, t.Port)] {
continue
}
seen[db.TargetKey(t.Host, t.Port)] = true
out = append(out, db.TargetExemption{Host: t.Host, Port: t.Port})
}
return out
}
// Exemptions returns the stored exemption list and its digest.
func (i *Interceptor) Exemptions() ([]db.TargetExemption, string, error) {
return i.targets.Exemptions()
}
// SetExemptions validates list and stores it as the exemption list; the change
// is appended to the exemption log with changedBy. An entry is an exact host
// and port: a name or an IP literal, no wildcard, no suffix, no CIDR, and a
// port between 1 and 65535. It returns the list as stored (hosts normalised,
// duplicates dropped) and its digest.
func (i *Interceptor) SetExemptions(list []db.TargetExemption, changedBy string) ([]db.TargetExemption, string, error) {
out := []db.TargetExemption{}
seen := map[string]bool{}
for _, e := range list {
kind, host, ok := normalizeHost(e.Host, true)
switch {
case !ok || kind == TargetCIDR || kind == TargetDomain && !validHostname(host):
return nil, "", fmt.Errorf("%w: %q is not an exact host name or IP address", ErrInvalidExemption, e.Host)
case e.Port < 1 || e.Port > 65535:
return nil, "", fmt.Errorf("%w: %s needs a port between 1 and 65535, got %d", ErrInvalidExemption, host, e.Port)
}
if err := ValidateExemptionHost(host); err != nil {
return nil, "", fmt.Errorf("%w: %v", ErrInvalidExemption, err)
}
if key := db.TargetKey(host, e.Port); !seen[key] {
seen[key] = true
out = append(out, db.TargetExemption{Host: host, Port: e.Port, Note: strings.TrimSpace(e.Note)})
}
}
raw, err := json.Marshal(out)
if err != nil {
return nil, "", err
}
digest := digestInput(raw)
if err := i.db.ReplaceTargetExemptions(out, changedBy, digest); err != nil {
return nil, "", err
}
return out, digest, nil
}
// exemptions is the exemption list as one gated call sees it.
type exemptions struct {
derived map[string]bool // db.TargetKey(host, port)
stored map[string]bool
digest string // of the stored list
}
func (i *Interceptor) loadExemptions() (exemptions, error) {
stored, digest, err := i.targets.Exemptions()
if err != nil {
return exemptions{}, err
}
ex := exemptions{derived: map[string]bool{}, stored: map[string]bool{}, digest: digest}
for _, e := range i.DerivedExemptions() {
ex.derived[db.TargetKey(e.Host, e.Port)] = true
}
for _, e := range stored {
ex.stored[db.TargetKey(e.Host, e.Port)] = true
}
return ex, nil
}
// voidsExemption reports whether one resolved address voids a stored
// exemption on a name: a private address (10/8, 172.16/12, 192.168/16, the
// unique local range fc00::/7), or one that is never exempt as a literal
// either (loopback, link-local 169.254.0.0/16 and fe80::/10, unspecified,
// multicast, instance metadata). One such address is enough.
func voidsExemption(ip string) bool {
a, err := netip.ParseAddr(ip)
if err != nil {
return true
}
a = a.Unmap().WithZone("")
return a.IsPrivate() || ValidateExemptionHost(a.String()) != nil
}