Files
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

419 lines
16 KiB
Go

package intercept
import (
"context"
"fmt"
"net/netip"
"strings"
"time"
"github.com/Autumn-27/artex/db"
)
// Block codes of the gate decision. Like the codes of a target request they
// are carried in the model-facing text of the blocked call. None of them opens
// an approval request.
const (
// TargetResolveFailed: a name did not resolve, so there is no address to
// compare with a pin or to show to a human.
TargetResolveFailed = "TARGET_RESOLVE_FAILED"
// TargetRuleBlocked: an asset block rule matches a target.
TargetRuleBlocked = "TARGET_RULE_BLOCKED"
// TargetTooMany: the call names more unpinned targets than one call may request.
TargetTooMany = "TARGET_TOO_MANY"
)
// maxUnpinnedTargets is how many distinct targets without a covering pin one
// call may request approval for. Each approval is one click on one host.
const maxUnpinnedTargets = 5
// literalResolver is the resolver recorded for an IP literal or a CIDR: the
// address is what was written, nothing was resolved.
const literalResolver = "literal"
// targetStore is what the gate reads and writes. The database implements it.
type targetStore interface {
// BlockRules returns the asset block rules that apply to the scope: the
// global ones and, for a task, its own. Allow rules play no role in the gate.
BlockRules(scope db.TargetScopeRef) ([]db.AssetInterceptRule, error)
// ActivePins returns the active pins that may cover (host, port) in scope,
// the pin on the exact port first.
ActivePins(scope db.TargetScopeRef, host string, port int) ([]*db.TargetPin, error)
// Exemptions returns the stored exemption list and its digest.
Exemptions() ([]db.TargetExemption, string, error)
// LogDecision records an outcome no human took.
LogDecision(db.TargetDecision) error
}
type dbTargetStore struct{ d *db.DB }
func (s dbTargetStore) BlockRules(scope db.TargetScopeRef) ([]db.AssetInterceptRule, error) {
rules, err := s.d.ListAssetInterceptRules()
if err != nil || scope.TaskID == 0 {
return rules, err
}
block, _, err := s.d.Assets().TaskInterceptRulesSplit(scope.TaskID)
if err != nil {
return nil, err
}
return append(rules, block...), nil
}
func (s dbTargetStore) ActivePins(scope db.TargetScopeRef, host string, port int) ([]*db.TargetPin, error) {
return s.d.ActiveTargetPins(scope, host, port)
}
func (s dbTargetStore) Exemptions() ([]db.TargetExemption, string, error) {
list, raw, err := s.d.TargetExemptions()
if raw == "" {
raw = "[]"
}
return list, digestInput([]byte(raw)), err
}
func (s dbTargetStore) LogDecision(r db.TargetDecision) error {
_, err := s.d.LogTargetDecision(r)
return err
}
// CheckTargets decides, for one tool call of a gated scope, whether every
// network target the call declares may be reached: extraction, asset block
// rules, exemptions, resolution and the pin lookup. It creates no approval
// request and never waits.
//
// A block is reported in the result (Code, Reason and the target that caused
// it) and logged as a decided row. Otherwise the returned requests are the
// targets no pin covers, each with the addresses a human has to read; the
// caller passes them to RequestTargets and checks again after the decisions.
// No request and no code means the call may run.
//
// With the gate off the call returns at once: nothing is extracted, resolved,
// read or written.
func (i *Interceptor) CheckTargets(ctx context.Context, scope TargetScope, toolName string, input []byte) ([]TargetRequest, TargetResult) {
if scope.Enabled == nil || !scope.Enabled() {
return nil, TargetResult{}
}
declared := extractDeclared(toolName, input)
if len(declared.targets) == 0 {
return nil, TargetResult{}
}
c := &targetCheck{i: i, ctx: ctx, scope: scope, tool: toolName, input: input, urls: declared.urls}
return c.run(declared.targets)
}
// targetCheck is one run of the gate decision.
type targetCheck struct {
i *Interceptor
ctx context.Context
scope TargetScope
ref db.TargetScopeRef
tool string
input []byte
urls []string // the URLs the call declares, as written, for the URL block rules
// redirectFrom is the origin (host:port) of a redirect hop; empty for a call.
redirectFrom string
// fresh makes every lookup skip the cache: the decision of a connection
// (dialTarget) compares the answer it is about to dial.
fresh bool
// checked is what the run decided about each target, read by dialTarget.
checked []*checkedTarget
}
// What let a target pass, recorded in checkedTarget.cover.
const (
coverDerived = "derived" // an exemption derived from the configuration
coverStored = "stored" // a stored exemption that holds
coverPin = "pin" // a pin that holds every resolved address
coverNameOnly = "name_only" // a pin that is name-only for this call
)
// checkedTarget is one target of the call and what the gate learned about it.
type checkedTarget struct {
Target
ips []string // resolved addresses; the literal itself for an IP or a CIDR
resolver string
resolvedAt time.Time
err error // the name did not resolve
exemptVoid bool
cover string // what let the target pass; empty when nothing did
}
func (t Target) String() string {
return targetName(TargetRequest{Host: t.Host, Port: t.Port})
}
func (t *checkedTarget) audit(outcome string) db.InterceptTarget {
return db.InterceptTarget{Host: t.Host, Port: t.Port, Kind: t.Kind, ResolvedIPs: t.ips,
Resolver: t.resolver, ResolvedAt: t.resolvedAt, Outcome: outcome, ExemptVoid: t.exemptVoid}
}
func (c *targetCheck) run(targets []Target) ([]TargetRequest, TargetResult) {
i := c.i
var err error
if c.ref, err = c.scope.ref(); err != nil {
return nil, TargetResult{Code: TargetGateUnavailable, Host: targets[0].Host, Port: targets[0].Port, Reason: err.Error()}
}
first := &checkedTarget{Target: targets[0]}
rules, err := i.targets.BlockRules(c.ref)
if err != nil {
return c.block(TargetGateUnavailable, "gate_unavailable", first, "the asset rules could not be read: "+err.Error())
}
ex, err := i.loadExemptions()
if err != nil {
return c.block(TargetGateUnavailable, "gate_unavailable", first, "the exemption list could not be read: "+err.Error())
}
// Block rules come first, and an exempt or pinned host that matches one is
// blocked. They are matched on what the call declares before anything is
// resolved: a hit there costs no lookup, for any target of the call.
//
// URL rules (exact_url, fuzzy_url) are matched once per decision, on the
// URLs as the call writes them. That is a known limit: exact_url compares
// the string as written, so another case, encoding (%2F), a trailing slash,
// an added query or /./ does not match, and fuzzy_url is evaded by encoding
// the substring; a command can also build its URL at run time. To keep an
// agent away from a host, a domain, IP or CIDR rule is the one to write.
if rule, u, hit := db.MatchAssetInterceptRules(rules, nil, nil, c.urls); hit {
ct := &checkedTarget{Target: targets[0]}
if t, ok := NormalizeTarget(u); ok {
ct.Target = t
}
return c.block(TargetRuleBlocked, "rule_blocked", ct, fmt.Sprintf("%s is blocked by an asset rule. %s", u, rule.Reason()))
}
// Then the declared form of every target: the name as written, the IP
// literal, the range.
checked := make([]*checkedTarget, len(targets))
c.checked = checked
for n, t := range targets {
ct := &checkedTarget{Target: t}
checked[n] = ct
if t.Kind != TargetDomain {
ct.ips, ct.resolver, ct.resolvedAt = []string{t.Host}, literalResolver, time.Now().UTC()
}
if rule, hit := blockRule(rules, ct); hit {
return c.block(TargetRuleBlocked, "rule_blocked", ct, fmt.Sprintf("%s is blocked by an asset rule. %s", t, rule.Reason()))
}
}
// Only then are the names resolved, and the rules matched on the addresses.
// A name the configuration exempts is never resolved.
for _, ct := range checked {
if ct.Kind != TargetDomain || ex.derived[db.TargetKey(ct.Host, ct.Port)] {
continue
}
c.resolve(ct, c.fresh)
if rule, hit := blockRule(rules, ct); hit {
return c.block(TargetRuleBlocked, "rule_blocked", ct, fmt.Sprintf("%s is blocked by an asset rule. %s", ct.Target, rule.Reason()))
}
}
i.mu.RLock()
proxied := i.upstreamProxy
i.mu.RUnlock()
// Behind the upstream proxy the proxy chooses the address it connects to, so
// every pin is name-only for this call, whatever the pin row says: the proxy
// can be turned on after a pin was approved.
nameOnly := proxied != nil && proxied()
var unpinned []TargetRequest
var uses []func()
for _, ct := range checked {
key := db.TargetKey(ct.Host, ct.Port)
if ex.derived[key] {
ct.cover = coverDerived
uses = append(uses, func() { c.exemptUsed(ct, ex.digest) })
continue
}
if ex.stored[key] {
if exemptionHolds(ct) {
ct.cover = coverStored
uses = append(uses, func() { c.exemptUsed(ct, ex.digest) })
continue
}
// A void exemption is not a block: the target goes on like any other.
ct.exemptVoid = true
}
if ct.err != nil {
return c.block(TargetResolveFailed, "resolve_failed", ct, c.resolveFailure(ct))
}
pins, err := i.targets.ActivePins(c.ref, ct.Host, ct.Port)
if err != nil {
return c.block(TargetGateUnavailable, "gate_unavailable", ct, "the pins could not be read: "+err.Error())
}
pin := coveringPin(pins, ct.ips, nameOnly)
if pin == nil && ct.Kind == TargetDomain && !c.fresh {
// A request is about to be raised: the addresses a human reads and
// pins are resolved now, never taken from the cache. A fresh run
// already has them.
if c.resolve(ct, true); ct.err != nil {
return c.block(TargetResolveFailed, "resolve_failed", ct, c.resolveFailure(ct))
}
if rule, hit := blockRule(rules, ct); hit {
return c.block(TargetRuleBlocked, "rule_blocked", ct, fmt.Sprintf("%s is blocked by an asset rule. %s", ct.Target, rule.Reason()))
}
pin = coveringPin(pins, ct.ips, nameOnly)
}
if pin != nil {
ct.cover = coverPin
if nameOnly || pin.NameOnlyEffective {
ct.cover = coverNameOnly
}
uses = append(uses, func() { c.pinUsed(ct, pin, nameOnly) })
continue
}
req := TargetRequest{Host: ct.Host, Port: ct.Port, Kind: ct.Kind, ResolvedIPs: ct.ips,
Resolver: ct.resolver, ResolvedAt: ct.resolvedAt, Reason: "no_pin", ExemptVoid: ct.exemptVoid}
if len(pins) > 0 {
// The request is raised on the key of the pin that exists (the one on
// the exact port when both exist), so that an approval extends that pin.
req.Port, req.Reason, req.Pin = pins[0].Port, "new_ip", pins[0]
}
if !hasRequest(unpinned, req) {
unpinned = append(unpinned, req)
}
}
if len(unpinned) > maxUnpinnedTargets {
names := make([]string, len(unpinned))
audit := make([]db.InterceptTarget, len(unpinned))
for n, r := range unpinned {
names[n] = targetName(r)
audit[n] = db.InterceptTarget{Host: r.Host, Port: r.Port, Kind: r.Kind, ResolvedIPs: r.ResolvedIPs,
Resolver: r.Resolver, ResolvedAt: r.ResolvedAt, Reason: r.Reason, Outcome: "too_many"}
}
reason := fmt.Sprintf("this call names %d targets that are not approved (%s); at most %d are requested per call",
len(unpinned), strings.Join(names, ", "), maxUnpinnedTargets)
c.log("denied", TargetTooMany+": "+reason, "", audit...)
return nil, TargetResult{Code: TargetTooMany, Host: unpinned[0].Host, Port: unpinned[0].Port, Reason: reason}
}
if len(unpinned) == 0 {
// The call passes: record what let it pass, once per process.
for _, use := range uses {
use()
}
}
return unpinned, TargetResult{}
}
// resolve fills the addresses of a name. fresh skips the cache.
func (c *targetCheck) resolve(ct *checkedTarget, fresh bool) {
ct.resolver = c.i.resolve.name()
res, err := c.i.resolve.lookup(c.ctx, ct.Host, fresh)
ct.ips, ct.resolvedAt, ct.err = res.ips, res.at, err
}
func (c *targetCheck) resolveFailure(ct *checkedTarget) string {
reason := fmt.Sprintf("the platform could not resolve %s (%v)", ct.Host, ct.err)
if pins, err := c.i.targets.ActivePins(c.ref, ct.Host, ct.Port); err == nil && len(pins) > 0 {
reason += fmt.Sprintf(", so it cannot compare the address with the approved pin (v%d: %s)",
pins[0].Version, strings.Join(pins[0].PinnedIPs, ", "))
}
return reason
}
// block logs a blocked call as a decided row and returns the block.
func (c *targetCheck) block(code, outcome string, ct *checkedTarget, reason string) ([]TargetRequest, TargetResult) {
c.log("denied", code+": "+reason, "", ct.audit(outcome))
return nil, TargetResult{Code: code, Host: ct.Host, Port: ct.Port, Reason: reason}
}
// log writes one decided row of the gate. Best effort: a failed write never
// changes the outcome of the call.
func (c *targetCheck) log(status, reason, configDigest string, targets ...db.InterceptTarget) {
action := "deny"
if status == "allowed" {
action = "allow"
}
audit := auditFor(c.ctx, Decision{Action: action, Message: reason, ConfigDigest: configDigest}, c.input, status)
if status == "allowed" {
// The row records that a pin or an exemption was used, not this one call.
audit.ExecutionStatus = "unknown"
}
for n := range targets {
targets[n].RedirectFrom = c.redirectFrom
}
audit.Targets = targets
_, agentName := taskInfoFromCtx(c.ctx)
_ = c.i.targets.LogDecision(db.TargetDecision{Scope: c.ref, AgentName: agentName, ToolName: c.tool,
ToolInput: c.input, Status: status, Reason: reason, Audit: audit})
}
// exemptUsed logs the first call of this process that passes through an
// exemption, per scope, host, port and list digest.
func (c *targetCheck) exemptUsed(ct *checkedTarget, digest string) {
key := "exempt|" + c.scope.scopeName() + "|" + db.TargetKey(ct.Host, ct.Port) + "|" + digest
if _, seen := c.i.firstUse.LoadOrStore(key, true); seen {
return
}
c.log("allowed", fmt.Sprintf("Target %s is exempt from target approval for %s.", ct.Target, c.scope.scopeName()),
digest, ct.audit("exempt"))
}
// pinUsed logs the first call of this process that a pin version covers.
// nameOnly is whether the upstream proxy made every pin name-only for the call:
// the row records the effective state, not only the pin's mark.
func (c *targetCheck) pinUsed(ct *checkedTarget, pin *db.TargetPin, nameOnly bool) {
if _, seen := c.i.firstUse.LoadOrStore(fmt.Sprintf("pin|%d", pin.ID), true); seen {
return
}
t := ct.audit("pin_match")
t.PinID, t.PinVersion, t.PinnedIPs, t.NameOnlyEffective = pin.ID, pin.Version, pin.PinnedIPs, pin.NameOnlyEffective || nameOnly
c.log("allowed", fmt.Sprintf("Target %s is covered by pin v%d of %s.", ct.Target, pin.Version, c.scope.scopeName()), "", t)
}
// blockRule matches the asset block rules against a target: its name, and its
// addresses. A range is matched as written and by its network address. URL
// rules are not matched here: run matches them once, on the declared URLs.
func blockRule(rules []db.AssetInterceptRule, ct *checkedTarget) (db.AssetInterceptRule, bool) {
var domains []string
ips := ct.ips
switch ct.Kind {
case TargetDomain:
domains = []string{ct.Host}
case TargetCIDR:
if p, err := netip.ParsePrefix(ct.Host); err == nil {
ips = []string{ct.Host, p.Addr().String()}
}
}
rule, _, hit := db.MatchAssetInterceptRules(rules, domains, ips, nil)
return rule, hit
}
// exemptionHolds reports whether the stored exemption on a target applies. On a
// name it is void when the name does not resolve or one of its addresses is
// private, loopback or otherwise local. An IP literal is not resolved; it holds
// unless it is an address that can never be exempt.
func exemptionHolds(ct *checkedTarget) bool {
if ct.Kind == TargetIP {
return ValidateExemptionHost(ct.Host) == nil
}
if ct.Kind != TargetDomain || ct.err != nil || len(ct.ips) == 0 {
return false
}
for _, ip := range ct.ips {
if voidsExemption(ip) {
return false
}
}
return true
}
// coveringPin returns the pin that covers the resolved addresses: the first
// candidate that is name-only, or that holds every address. The address sets
// of two pins are never merged.
func coveringPin(pins []*db.TargetPin, resolved []string, nameOnly bool) *db.TargetPin {
for _, p := range pins {
if nameOnly || p.NameOnlyEffective || subset(resolved, p.PinnedIPs) {
return p
}
}
return nil
}
func hasRequest(list []TargetRequest, r TargetRequest) bool {
for _, x := range list {
if x.Host == r.Host && x.Port == r.Port {
return true
}
}
return false
}