Files
ARTEX/db/asset_intercept_match.go
BaaRai ca15063db7 fix(intercept): match block rules on the declared targets before any lookup
The target gate resolved each name through the cache before it matched the
asset block rules, so a call to a name that a name rule blocks still cost a
DNS lookup, and that query reached the name servers of the blocked domain.
URL rules (exact_url, fuzzy_url) were not evaluated at dispatch at all: the
gate passed no URL to the matcher, so they only applied to asset and intent
writes.

targetCheck.run now decides in this order, for a gated call:

1. URL rules, once per decision, on the URLs the call declares, as written.
2. Every block rule on the declared form of every target: the name as
   written, the IP literal, the range.
3. Only then resolution (names a derived exemption covers are still never
   resolved), and the block rules again on the resolved addresses.
4. Exemptions, pins and requests, unchanged.

A hit in 1 or 2 blocks with TARGET_RULE_BLOCKED and the same decided row as
any block, costs no lookup for any target of the call, and opens no request.

- intercept.extractDeclared returns the targets (ExtractTargets is now a thin
  wrapper, unchanged for its callers) and the URLs that name a target: the
  WebFetch url, and every URL token the command-string rules read in Bash,
  shell_open, Monitor, shell_send and the string leaves of other tools,
  including ExecuteExtraTool's inner call.
- The custom http tool's rendered URL and the URLs of a rendered command go to
  the same URL rules (gateRendered takes the URLs).
- Known limit, stated in the comments next to the matcher (db.matchOne) and
  at the dispatch match: URL rules match the declared string. exact_url misses
  any other spelling (case, percent-encoding, trailing slash, added query,
  /./), fuzzy_url misses an encoded substring, and a command can build its URL
  at run time. For a host, a domain, IP or CIDR rule is the one to write.

Unchanged: a tool-rule or judge deny still blocks before the gate is reached
(no extraction, no lookup, no request); asset block rules still win over an
allow and an ask; the gate off path does nothing; scripts stay advisory.

Tests:
- intercept: TestBlockRuleOnDeclaredNameNeedsNoLookup (exact_domain and
  fuzzy_domain; zero lookups, also for an unblocked name earlier in the same
  call), TestExtractDeclaredURLs, TestURLBlockRuleAtDispatch (WebFetch, Bash,
  MCP, ExecuteExtraTool, a pinned and an exempt host, a disabled rule, the
  rendered URL and command; zero lookups), TestExactURLRuleMatchesOnlyAsWritten
  (documents the limit).
- guard: TestBlockRuleWinsOverAllowAndAsk (name rule and URL rule, each over a
  rule allow, judge allow, rule ask and judge ask, real interceptor: blocked,
  no allow row, nobody asked, zero lookups). TestDenyRuleWinsBeforeTargetRequest
  now also covers a judge deny and asserts zero lookups with the real gate.
- server: TestBypass_CustomScriptToolTargetNotGated documents that a script
  tool's target, written in its stored code, is not visible to the gate: in a
  gated task the call is allowed and reaches the target with no lookup, no
  request and no row.
2026-11-11 15:47:58 +01:00

268 lines
8.0 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package db
import (
"fmt"
"net"
"net/url"
"strings"
)
// Matching/enforcement layer for asset interception rules. asset_intercept.go only
// stores rules; this file matches a target asset's domain/IP/URL candidates against
// the enabled rules. Called by agent tools (add_intent, insert_assets) before
// dispatching an intent / inserting assets; a hit means rejection.
// AssetInterceptKindLabel returns the human-readable label for kind, used in
// explanatory messages to the agent.
func AssetInterceptKindLabel(kind string) string {
switch kind {
case "exact_domain":
return "Domain (exact)"
case "exact_ip":
return "IP (exact)"
case "exact_url":
return "URL (exact)"
case "fuzzy_domain":
return "Domain (fuzzy)"
case "fuzzy_ip":
return "IP (fuzzy)"
case "fuzzy_url":
return "URL (fuzzy)"
case "cidr":
return "CIDR range"
}
return kind
}
// Reason returns a readable hit reason, e.g.: Matched asset interception rule [Domain (fuzzy): .gov.cn] (note).
func (r AssetInterceptRule) Reason() string {
s := fmt.Sprintf("Matched asset interception rule [%s: %s]", AssetInterceptKindLabel(r.Kind), r.Pattern)
if note := strings.TrimSpace(r.Note); note != "" {
s += " (" + note + ")"
}
return s
}
// matchOne checks whether a single enabled rule matches the given domain/IP/URL
// candidates, and returns the concrete matched value.
//
// URL rules match the URL string as given, with no normalisation. Known limit:
// exact_url misses any other spelling of the same URL (case, percent-encoding
// such as %2F, a trailing slash, an added query, /./), and fuzzy_url misses an
// encoded form of its substring. At tool dispatch the string is the URL the
// call declares (intercept, targetCheck.run).
func matchOne(r AssetInterceptRule, domains, ips, urls []string) (string, bool) {
p := strings.TrimSpace(r.Pattern)
if p == "" {
return "", false
}
switch r.Kind {
case "exact_domain":
for _, d := range domains {
if strings.EqualFold(strings.TrimSpace(d), p) {
return d, true
}
}
case "exact_ip":
for _, ip := range ips {
if strings.TrimSpace(ip) == p {
return ip, true
}
}
case "exact_url":
for _, u := range urls {
if strings.TrimSpace(u) == p {
return u, true
}
}
case "fuzzy_domain":
lp := strings.ToLower(p)
for _, d := range domains {
if d != "" && strings.Contains(strings.ToLower(d), lp) {
return d, true
}
}
case "fuzzy_ip":
for _, ip := range ips {
if ip != "" && strings.Contains(ip, p) {
return ip, true
}
}
case "fuzzy_url":
lp := strings.ToLower(p)
for _, u := range urls {
if u != "" && strings.Contains(strings.ToLower(u), lp) {
return u, true
}
}
case "cidr":
_, ipnet, err := net.ParseCIDR(p)
if err != nil {
return "", false
}
for _, ip := range ips {
if pip := net.ParseIP(strings.TrimSpace(ip)); pip != nil && ipnet.Contains(pip) {
return ip, true
}
}
}
return "", false
}
// MatchAssetInterceptRules returns the first enabled rule matching the given
// domain/IP/URL candidates, plus the concrete matched value.
// Used by insert_assets to match raw input (assetInputItem not yet persisted).
func MatchAssetInterceptRules(rules []AssetInterceptRule, domains, ips, urls []string) (AssetInterceptRule, string, bool) {
for _, r := range rules {
if !r.Enabled {
continue
}
if v, ok := matchOne(r, domains, ips, urls); ok {
return r, v, true
}
}
return AssetInterceptRule{}, "", false
}
// interceptCandidates extracts a persisted asset's domain/IP/URL candidates for
// interception matching. A URL's host is split out and classified, so service
// assets that only carry a URL can still be hit by domain/IP rules.
func (a *Asset) interceptCandidates() (domains, ips, urls []string) {
add := func(dst *[]string, s string) {
if s = strings.TrimSpace(s); s != "" {
*dst = append(*dst, s)
}
}
add(&domains, a.Domain)
add(&domains, a.RootDomain)
for _, d := range a.BoundDomains {
add(&domains, d)
}
add(&ips, a.IP)
add(&urls, a.URL)
if a.URL != "" {
if u, err := url.Parse(a.URL); err == nil {
if h := u.Hostname(); h != "" {
if net.ParseIP(h) != nil {
add(&ips, h)
} else {
add(&domains, h)
}
}
}
}
return domains, ips, urls
}
// InterceptLabel returns a short identifier for the asset, used in explanatory
// messages to the agent.
func (a *Asset) InterceptLabel() string {
var target string
switch {
case a.Domain != "":
target = a.Domain
case a.URL != "":
target = a.URL
case a.IP != "":
target = a.IP
default:
target = fmt.Sprintf("#%d", a.ID)
}
return fmt.Sprintf("Asset #%d[%s] %s", a.ID, a.Type, target)
}
// hasEnabledRule reports whether the rule set contains any enabled rule.
func hasEnabledRule(rules []AssetInterceptRule) bool {
for _, r := range rules {
if r.Enabled {
return true
}
}
return false
}
// AssetGateDecision is the result of the "intercept first, then allow" gate over
// a set of candidate strings.
type AssetGateDecision struct {
Allowed bool
Reason string // rejection reason (without the asset identifier); empty when Allowed=true
}
// EvaluateAssetGate performs the task-level gate decision:
// 1. Any enabled blockRules hit → reject (interception reason).
// 2. Otherwise, if allowRules has enabled entries and none match → reject (outside the allowed scope).
// 3. Otherwise allow.
//
// When allowRules is empty / has no enabled entries, the allow gate is inert
// (i.e. no whitelist, everything is allowed), so that "no allow rules
// configured" does not block every asset.
func EvaluateAssetGate(blockRules, allowRules []AssetInterceptRule, domains, ips, urls []string) AssetGateDecision {
if rule, _, ok := MatchAssetInterceptRules(blockRules, domains, ips, urls); ok {
return AssetGateDecision{Allowed: false, Reason: rule.Reason()}
}
if hasEnabledRule(allowRules) {
if _, _, ok := MatchAssetInterceptRules(allowRules, domains, ips, urls); !ok {
return AssetGateDecision{Allowed: false, Reason: "Not in the task's allowed (whitelist) scope; testing is not permitted"}
}
}
return AssetGateDecision{Allowed: true}
}
// AssetInterceptHit describes an asset rejected by the gate (interception hit or
// outside the allowed scope).
type AssetInterceptHit struct {
Asset *Asset
Reason string // readable reason
}
// Describe returns a readable explanation: asset info + reason.
func (h AssetInterceptHit) Describe() string {
return fmt.Sprintf("%s → %s", h.Asset.InterceptLabel(), h.Reason)
}
// ListAssetInterceptRules is a pass-through of the *DB method of the same name,
// so callers that only hold an AssetStore (e.g. agent tools) can also read rules.
func (s *AssetStore) ListAssetInterceptRules() ([]AssetInterceptRule, error) {
return s.db.ListAssetInterceptRules()
}
// CheckAssetsIntercept loads assets by id, runs the "intercept first, then allow"
// gate on each, and returns all rejected assets. Interception rules = global ∪
// task-level block; allow rules = task-level allow (this task only).
// Returns fast when there are no ids. Uses the global GetByIDs (not filtered by
// task scope) so interception is never weakened by scope.
func (s *AssetStore) CheckAssetsIntercept(taskID int64, ids []int64) ([]AssetInterceptHit, error) {
if len(ids) == 0 {
return nil, nil
}
blockRules, err := s.db.ListAssetInterceptRules()
if err != nil {
return nil, err
}
var allowRules []AssetInterceptRule
if taskID > 0 {
tb, ta, err := s.TaskInterceptRulesSplit(taskID)
if err != nil {
return nil, err
}
blockRules = append(blockRules, tb...)
allowRules = ta
}
// No interception rules and no enabled allow rules → nothing to decide, allow all.
if len(blockRules) == 0 && !hasEnabledRule(allowRules) {
return nil, nil
}
assets, err := s.GetByIDs(ids)
if err != nil {
return nil, err
}
var hits []AssetInterceptHit
for _, a := range assets {
domains, ips, urls := a.interceptCandidates()
if d := EvaluateAssetGate(blockRules, allowRules, domains, ips, urls); !d.Allowed {
hits = append(hits, AssetInterceptHit{Asset: a, Reason: d.Reason})
}
}
return hits, nil
}