mirror of
https://github.com/BaaRai/ARTEX.git
synced 2026-11-13 11:40:53 +08:00
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.
156 lines
5.8 KiB
Go
156 lines
5.8 KiB
Go
package intercept
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"net/url"
|
|
"strings"
|
|
)
|
|
|
|
// The custom tools render their target from a template and the parameters of
|
|
// the call, after the hook has seen the call: the hook reads the parameters, not
|
|
// the URL or the command they produce, and not a target written in the
|
|
// template itself. These functions run the gate inside the tool, on what the
|
|
// tool is about to run, before anything is sent or started.
|
|
|
|
// renderedPasses bounds the request-and-recheck loop of a rendered target, as
|
|
// the hook bounds its own: a request that was decided in between, or addresses
|
|
// that changed again after an approval, send the call back to the pin lookup at
|
|
// most this many times.
|
|
const renderedPasses = 3
|
|
|
|
// GateRenderedURL is the target gate for the URL a custom http tool renders. It
|
|
// reads the target as the HTTP client will: the host of the parsed URL and its
|
|
// port, else the default port of the scheme. A URL whose target cannot be read
|
|
// is blocked with TARGET_GATE_UNAVAILABLE: the call is not executed and no
|
|
// approval is requested.
|
|
//
|
|
// The result is empty when the tool may run. With the gate off, or with no
|
|
// scope in ctx (the operator's test endpoint), it returns at once: nothing is
|
|
// parsed, resolved, read or written.
|
|
func GateRenderedURL(ctx context.Context, toolName, rawURL string) TargetResult {
|
|
scope, on := renderedScope(ctx)
|
|
if !on {
|
|
return TargetResult{}
|
|
}
|
|
input := renderedInput("url", rawURL)
|
|
t, err := urlTarget(rawURL)
|
|
if err != nil {
|
|
return undeterminedTarget(ctx, scope, toolName, input, "the target of the rendered URL could not be read ("+err.Error()+")")
|
|
}
|
|
return gateRendered(ctx, scope, toolName, input, []Target{t}, []string{rawURL})
|
|
}
|
|
|
|
// GateRenderedCommand is the target gate for the command a custom command tool
|
|
// renders, read with the command-string rules of Bash. readings are other
|
|
// renderings of the same command that are read as well: the shell quoting of a
|
|
// parameter placed inside a host name hides that name from the rules, and the
|
|
// shell removes the quotes before the command runs. A command that names no
|
|
// target passes, as it does in Bash: what a command hides is advisory (D9).
|
|
//
|
|
// The result is empty when the tool may run. With the gate off, or with no
|
|
// scope in ctx, it returns at once.
|
|
func GateRenderedCommand(ctx context.Context, toolName, command string, readings ...string) TargetResult {
|
|
scope, on := renderedScope(ctx)
|
|
if !on {
|
|
return TargetResult{}
|
|
}
|
|
var l targetList
|
|
l.scan(command)
|
|
for _, r := range readings {
|
|
l.scan(r)
|
|
}
|
|
return gateRendered(ctx, scope, toolName, renderedInput("command", command), l.targets, l.urls)
|
|
}
|
|
|
|
func renderedScope(ctx context.Context) (TargetScope, bool) {
|
|
scope, ok := TargetScopeFromContext(ctx)
|
|
return scope, ok && scope.Enabled != nil && scope.Enabled()
|
|
}
|
|
|
|
// renderedInput is the tool input recorded with a rendered target: what the
|
|
// tool runs, which is what the human reads on the approval.
|
|
func renderedInput(field, value string) []byte {
|
|
b, _ := json.Marshal(map[string]string{field: value})
|
|
return b
|
|
}
|
|
|
|
// gateRendered runs the gate decision on the targets of a rendered call, asks
|
|
// for the approval of those no pin covers, and checks again after the
|
|
// decisions. urls are the URLs the call renders, for the URL block rules. It
|
|
// never merges an ask: the rules and the judge decided at the hook, before the
|
|
// tool ran.
|
|
func gateRendered(ctx context.Context, scope TargetScope, toolName string, input []byte, targets []Target, urls []string) TargetResult {
|
|
if len(targets) == 0 {
|
|
return TargetResult{}
|
|
}
|
|
i := scope.Interceptor
|
|
if i == nil {
|
|
return TargetResult{Code: TargetGateUnavailable, Host: targets[0].Host, Port: targets[0].Port,
|
|
Reason: "the session's target scope carries no gate"}
|
|
}
|
|
ctx = WithCall(ctx, toolName, input)
|
|
check := func() ([]TargetRequest, TargetResult) {
|
|
if !scope.Enabled() {
|
|
return nil, TargetResult{}
|
|
}
|
|
c := &targetCheck{i: i, ctx: ctx, scope: scope, tool: toolName, input: input, urls: urls}
|
|
return c.run(targets)
|
|
}
|
|
unpinned, res := check()
|
|
for pass := 0; res.Code == "" && len(unpinned) > 0; pass++ {
|
|
if pass == renderedPasses {
|
|
t := unpinned[0]
|
|
return TargetResult{Code: TargetApprovalPending, Host: t.Host, Port: t.Port,
|
|
Reason: fmt.Sprintf("the target is still not covered by a pin after %d approval rounds; its addresses changed again", renderedPasses)}
|
|
}
|
|
if res = i.RequestTargets(ctx, scope, toolName, input, unpinned, nil); res.Code != "" {
|
|
return res
|
|
}
|
|
unpinned, res = check()
|
|
}
|
|
return res
|
|
}
|
|
|
|
// undeterminedTarget blocks a call whose target cannot be read, and logs the
|
|
// block as a decided row.
|
|
func undeterminedTarget(ctx context.Context, scope TargetScope, toolName string, input []byte, reason string) TargetResult {
|
|
res := TargetResult{Code: TargetGateUnavailable, Reason: reason}
|
|
if scope.Interceptor == nil {
|
|
return res
|
|
}
|
|
ref, err := scope.ref()
|
|
if err != nil {
|
|
return res
|
|
}
|
|
c := &targetCheck{i: scope.Interceptor, ctx: WithCall(ctx, toolName, input), scope: scope, ref: ref, tool: toolName, input: input}
|
|
_, res = c.block(TargetGateUnavailable, "gate_unavailable", &checkedTarget{}, reason)
|
|
return res
|
|
}
|
|
|
|
// urlTarget reads the target of a URL the way net/http dials it: the host of
|
|
// the parsed URL, and its port, else the default port of the scheme.
|
|
func urlTarget(raw string) (Target, error) {
|
|
u, err := url.Parse(raw)
|
|
if err != nil {
|
|
return Target{}, err
|
|
}
|
|
name := u.Hostname()
|
|
if name == "" {
|
|
return Target{}, errors.New("no host")
|
|
}
|
|
kind, host, ok := normalizeHost(name, true)
|
|
if !ok {
|
|
return Target{}, fmt.Errorf("host %q", name)
|
|
}
|
|
t := Target{Kind: kind, Host: host, Port: schemePorts[strings.ToLower(u.Scheme)]}
|
|
if p := u.Port(); p != "" {
|
|
if t.Port = validPort(p); t.Port == 0 {
|
|
return Target{}, fmt.Errorf("port %q", p)
|
|
}
|
|
}
|
|
return t, nil
|
|
}
|