mirror of
https://github.com/BaaRai/ARTEX.git
synced 2026-11-14 04:00:54 +08:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ca15063db7 | ||
|
|
a77851db7d | ||
|
|
c0b741bcf6 | ||
|
|
ec3db7fdec | ||
|
|
78f92f44fc | ||
|
|
923c4a813a | ||
|
|
1f4cb65ca6 | ||
|
|
ce9f4d0107 |
@@ -70,7 +70,7 @@ func TestCaptureApprovalLifecycle(t *testing.T) {
|
||||
t.Error(err)
|
||||
return
|
||||
}
|
||||
if err := ic.Decide(detail.ID, tc.approve); err != nil {
|
||||
if err := ic.Decide(detail.ID, tc.approve, "", ""); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/Autumn-27/artex/intercept"
|
||||
)
|
||||
|
||||
// The domain tools read and write the platform's own records (the exploration
|
||||
// graph, assets, findings, goals). The target gate must find no target in
|
||||
// their input: an intent or a finding that mentions a URL is not a connection.
|
||||
// The gate scans every tool it does not know, so a domain tool added here has
|
||||
// to be added to the gate's list of tools without a target.
|
||||
func TestDomainToolsDeclareNoTarget(t *testing.T) {
|
||||
seeds := BuiltinToolSeeds()
|
||||
if len(seeds) < 20 {
|
||||
t.Fatalf("only %d domain tools found", len(seeds))
|
||||
}
|
||||
input := []byte(`{"summary":"probe https://app.example.com/login","items":[{"url":"https://app.example.com:8443/","ip":"203.0.113.7"}],` +
|
||||
`"poc":"curl -sk https://app.example.com/ -x http://127.0.0.1:8080","scope":"203.0.113.0/24"}`)
|
||||
if targets := intercept.ExtractTargets("a_tool_the_gate_does_not_know", input); len(targets) == 0 {
|
||||
t.Fatal("control: the input names targets, and an unknown tool is scanned")
|
||||
}
|
||||
for _, seed := range seeds {
|
||||
if targets := intercept.ExtractTargets(seed.Key, input); len(targets) != 0 {
|
||||
t.Errorf("%s: the gate reads targets %+v from a domain tool", seed.Key, targets)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -45,6 +45,12 @@ func (r AssetInterceptRule) Reason() string {
|
||||
|
||||
// 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 == "" {
|
||||
|
||||
+51
-15
@@ -58,20 +58,24 @@ type Conversation struct {
|
||||
PinnedAt *time.Time `json:"pinned_at,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
// TargetGate is the per-conversation opt-in of the approval gate for asset
|
||||
// targets (a chat has no task). false (default) = no gate.
|
||||
TargetGate bool `json:"target_gate"`
|
||||
}
|
||||
|
||||
// ConversationPatch updates only the fields whose pointers are non-nil.
|
||||
type ConversationPatch struct {
|
||||
Title *string
|
||||
Pinned *bool
|
||||
Title *string
|
||||
Pinned *bool
|
||||
TargetGate *bool
|
||||
}
|
||||
|
||||
const convCols = `id, agent_key, title, llm_profile_id, pinned_at, created_at, updated_at`
|
||||
const convCols = `id, agent_key, title, llm_profile_id, pinned_at, created_at, updated_at, target_gate`
|
||||
|
||||
func scanConv(row interface{ Scan(...any) error }) (Conversation, error) {
|
||||
var c Conversation
|
||||
var pinnedAt sql.NullTime
|
||||
err := row.Scan(&c.ID, &c.AgentKey, &c.Title, &c.LLMProfileID, &pinnedAt, &c.CreatedAt, &c.UpdatedAt)
|
||||
err := row.Scan(&c.ID, &c.AgentKey, &c.Title, &c.LLMProfileID, &pinnedAt, &c.CreatedAt, &c.UpdatedAt, &c.TargetGate)
|
||||
if pinnedAt.Valid {
|
||||
c.Pinned = true
|
||||
c.PinnedAt = &pinnedAt.Time
|
||||
@@ -82,6 +86,12 @@ func scanConv(row interface{ Scan(...any) error }) (Conversation, error) {
|
||||
// CreateConversation opens a new chat thread for agentKey with an initial title.
|
||||
// llmProfileID may be nil to use the globally active profile.
|
||||
func (d *DB) CreateConversation(agentKey, title string, llmProfileID *int64) (*Conversation, error) {
|
||||
return d.CreateConversationGated(agentKey, title, llmProfileID, false)
|
||||
}
|
||||
|
||||
// CreateConversationGated is CreateConversation with the target-gate opt-in set
|
||||
// at creation.
|
||||
func (d *DB) CreateConversationGated(agentKey, title string, llmProfileID *int64, targetGate bool) (*Conversation, error) {
|
||||
tx, err := d.Begin()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -92,8 +102,8 @@ func (d *DB) CreateConversation(agentKey, title string, llmProfileID *int64) (*C
|
||||
// by this transaction before it takes a profile lock, matching DeleteProfile's
|
||||
// child-row -> profile-row protocol.
|
||||
c, err := scanConv(tx.QueryRow(`
|
||||
INSERT INTO conversations(agent_key, title, llm_profile_id) VALUES ($1, $2, NULL)
|
||||
RETURNING `+convCols, agentKey, title))
|
||||
INSERT INTO conversations(agent_key, title, llm_profile_id, target_gate) VALUES ($1, $2, NULL, $3)
|
||||
RETURNING `+convCols, agentKey, title, targetGate))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -169,7 +179,7 @@ func (d *DB) GetConversation(id int64) (*Conversation, error) {
|
||||
return &c, nil
|
||||
}
|
||||
|
||||
// UpdateConversation applies a partial title/pin mutation and returns the updated
|
||||
// UpdateConversation applies a partial title/pin/target-gate mutation and returns the updated
|
||||
// row. Pinning an already-pinned conversation preserves its original pin order.
|
||||
func (d *DB) UpdateConversation(id int64, patch ConversationPatch) (*Conversation, error) {
|
||||
c, err := scanConv(d.QueryRow(`UPDATE conversations SET
|
||||
@@ -178,9 +188,10 @@ func (d *DB) UpdateConversation(id int64, patch ConversationPatch) (*Conversatio
|
||||
WHEN $4::boolean IS NULL THEN pinned_at
|
||||
WHEN $4::boolean THEN COALESCE(pinned_at, now())
|
||||
ELSE NULL
|
||||
END
|
||||
END,
|
||||
target_gate = CASE WHEN $5::boolean THEN $6 ELSE target_gate END
|
||||
WHERE id=$1
|
||||
RETURNING `+convCols, id, patch.Title != nil, patch.Title, patch.Pinned))
|
||||
RETURNING `+convCols, id, patch.Title != nil, patch.Title, patch.Pinned, patch.TargetGate != nil, patch.TargetGate))
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
@@ -203,19 +214,37 @@ func (d *DB) TouchConversation(id int64) error {
|
||||
return err
|
||||
}
|
||||
|
||||
// DeleteConversation removes a thread; its activities cascade via FK.
|
||||
// DeleteConversation removes a thread; its activities cascade via FK. Its
|
||||
// target pins are not rows of the thread: they are revoked in the same
|
||||
// transaction and stay as history.
|
||||
func (d *DB) DeleteConversation(id int64) error {
|
||||
_, err := d.Exec(`DELETE FROM conversations WHERE id=$1`, id)
|
||||
return err
|
||||
tx, err := d.Begin()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
if err := revokeConversationPins(tx, []int64{id}); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := tx.Exec(`DELETE FROM conversations WHERE id=$1`, id); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
// DeleteConversations removes existing threads in one statement and returns the
|
||||
// ids that were actually present. Child activities and trigger runs cascade.
|
||||
// ids that were actually present. Child activities and trigger runs cascade. The
|
||||
// target pins of the removed threads are revoked in the same transaction.
|
||||
func (d *DB) DeleteConversations(ids []int64) ([]int64, error) {
|
||||
if len(ids) == 0 {
|
||||
return []int64{}, nil
|
||||
}
|
||||
rows, err := d.Query(`DELETE FROM conversations WHERE id=ANY($1::bigint[]) RETURNING id`, ids)
|
||||
tx, err := d.Begin()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
rows, err := tx.Query(`DELETE FROM conversations WHERE id=ANY($1::bigint[]) RETURNING id`, ids)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -228,7 +257,14 @@ func (d *DB) DeleteConversations(ids []int64) ([]int64, error) {
|
||||
}
|
||||
deleted = append(deleted, id)
|
||||
}
|
||||
return deleted, rows.Err()
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rows.Close()
|
||||
if err := revokeConversationPins(tx, deleted); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return deleted, tx.Commit()
|
||||
}
|
||||
|
||||
// AppendConvActivity records one step of a conversation (human message or an agent
|
||||
|
||||
@@ -320,6 +320,9 @@ ON CONFLICT (name) DO NOTHING`,
|
||||
if err := d.seedDefaultAssetInterceptRules(); err != nil {
|
||||
return fmt.Errorf("seed asset intercept rules: %w", err)
|
||||
}
|
||||
if err := d.seedTargetGateExemptions(); err != nil {
|
||||
return fmt.Errorf("seed target-gate exemptions: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -114,8 +114,11 @@ func (d *DB) CreateFindingRetest(ctx context.Context, findingID int64, notes str
|
||||
if runes := []rune(title); len(runes) > 100 {
|
||||
title = string(runes[:100])
|
||||
}
|
||||
c, err := scanConv(tx.QueryRowContext(ctx, `INSERT INTO conversations(agent_key,title) VALUES ($1,$2) RETURNING `+convCols,
|
||||
FindingRetestAgentKey, fmt.Sprintf("Retest #%d · %s", findingID, title)))
|
||||
// The retest conversation takes the target-gate opt-in of the finding's task.
|
||||
// Pins are never copied: a pin belongs to the scope it was approved in.
|
||||
c, err := scanConv(tx.QueryRowContext(ctx, `INSERT INTO conversations(agent_key,title,target_gate) VALUES ($1,$2,
|
||||
COALESCE((SELECT t.target_gate FROM findings f JOIN tasks t ON t.id=f.task_id WHERE f.id=$3), false)) RETURNING `+convCols,
|
||||
FindingRetestAgentKey, fmt.Sprintf("Retest #%d · %s", findingID, title), findingID))
|
||||
if err != nil {
|
||||
return nil, nil, false, err
|
||||
}
|
||||
|
||||
+50
-2
@@ -43,6 +43,39 @@ type InterceptAudit struct {
|
||||
Output string `json:"output,omitempty"`
|
||||
OutputTruncated bool `json:"output_truncated,omitempty"`
|
||||
ExecutionEndedAt *time.Time `json:"execution_ended_at,omitempty"`
|
||||
// Targets is set on the rows of the target gate only (decision_source = target):
|
||||
// approval requests, blocks, and the first use of an exemption or pin version.
|
||||
Targets []InterceptTarget `json:"targets,omitempty"`
|
||||
// MergedAsk marks a target request that also carries the ask of a rule or of
|
||||
// the judge: one row, one decision for both.
|
||||
MergedAsk bool `json:"merged_ask,omitempty"`
|
||||
// Approver and ApproverAddr identify the human behind a decision: the subject
|
||||
// of the session token and the remote address of the request. decided_by keeps
|
||||
// the actor class (human).
|
||||
Approver string `json:"approver,omitempty"`
|
||||
ApproverAddr string `json:"approver_addr,omitempty"`
|
||||
}
|
||||
|
||||
// InterceptTarget is one asset target of a target approval request: what was
|
||||
// asked for, the addresses shown to the human, and the pin it relates to.
|
||||
type InterceptTarget struct {
|
||||
Host string `json:"host"`
|
||||
Port int `json:"port"` // 0 = port not declared
|
||||
Kind string `json:"kind,omitempty"`
|
||||
ResolvedIPs []string `json:"resolved_ips"`
|
||||
Resolver string `json:"resolver,omitempty"`
|
||||
ResolvedAt time.Time `json:"resolved_at"`
|
||||
PinID int64 `json:"pin_id,omitempty"`
|
||||
PinVersion int `json:"pin_version,omitempty"`
|
||||
PinnedIPs []string `json:"pinned_ips,omitempty"`
|
||||
Reason string `json:"reason,omitempty"` // no_pin | new_ip | revoked
|
||||
Outcome string `json:"outcome"` // requested | pinned | denied | pin_match | exempt | rule_blocked | resolve_failed | too_many | gate_unavailable
|
||||
// NameOnlyEffective: requested while the upstream egress proxy was set; a pin
|
||||
// created from this request covers the name only.
|
||||
NameOnlyEffective bool `json:"name_only_effective,omitempty"`
|
||||
// ExemptVoid: a stored exemption names this target, and did not apply because
|
||||
// the name resolved to a private, loopback or otherwise local address.
|
||||
ExemptVoid bool `json:"exempt_void,omitempty"`
|
||||
}
|
||||
|
||||
type InterceptDetail struct {
|
||||
@@ -77,6 +110,14 @@ func (d *DB) GetInterceptDetail(id int64) (*InterceptDetail, error) {
|
||||
// decidedBy records the settling actor (human | rule | model | system); it is
|
||||
// supplied by the caller because status alone cannot tell them apart.
|
||||
func (d *DB) ResolveIntercept(id int64, status, action, reason, decidedBy string) (bool, error) {
|
||||
return d.ResolveInterceptBy(id, status, action, reason, decidedBy, "", "")
|
||||
}
|
||||
|
||||
// ResolveInterceptBy is ResolveIntercept for a decision whose author is known:
|
||||
// approver (subject of the session token) and remoteAddr (address of the
|
||||
// request) are recorded in the row's audit when non-empty. A row without an
|
||||
// audit keeps none.
|
||||
func (d *DB) ResolveInterceptBy(id int64, status, action, reason, decidedBy, approver, remoteAddr string) (bool, error) {
|
||||
switch decidedBy {
|
||||
case "human", "rule", "model", "system":
|
||||
default:
|
||||
@@ -86,9 +127,16 @@ func (d *DB) ResolveIntercept(id int64, status, action, reason, decidedBy string
|
||||
if action == "allow" {
|
||||
execution = "awaiting_result"
|
||||
}
|
||||
patch, err := json.Marshal(map[string]any{
|
||||
fields := map[string]any{
|
||||
"effective_action": action, "decision_reason": reason, "execution_status": execution,
|
||||
})
|
||||
}
|
||||
if approver != "" {
|
||||
fields["approver"] = approver
|
||||
}
|
||||
if remoteAddr != "" {
|
||||
fields["approver_addr"] = remoteAddr
|
||||
}
|
||||
patch, err := json.Marshal(fields)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
@@ -558,6 +558,8 @@ CREATE TRIGGER trg_tasks_upd BEFORE UPDATE ON tasks
|
||||
-- planner heartbeat trigger interval (seconds); backfills old databases. Default 300s (5min). See docs/planner-trigger-impl-plan.md
|
||||
ALTER TABLE tasks ADD COLUMN IF NOT EXISTS plan_heartbeat_seconds INTEGER NOT NULL DEFAULT 300;
|
||||
ALTER TABLE tasks ADD COLUMN IF NOT EXISTS plan_quiet_mode BOOLEAN NOT NULL DEFAULT true;
|
||||
-- Approval gate for asset targets, opt-in per task; backfills old databases. false (default) = no gate, behaviour unchanged.
|
||||
ALTER TABLE tasks ADD COLUMN IF NOT EXISTS target_gate BOOLEAN NOT NULL DEFAULT false;
|
||||
ALTER TABLE tasks ADD COLUMN IF NOT EXISTS llm_rotate BOOLEAN NOT NULL DEFAULT false;
|
||||
-- Concurrency-limit hold state; backfills old databases. true=queued due to the concurrency limit, auto-starts when a slot frees up.
|
||||
ALTER TABLE tasks ADD COLUMN IF NOT EXISTS queued BOOLEAN NOT NULL DEFAULT false;
|
||||
@@ -995,6 +997,8 @@ CREATE TABLE IF NOT EXISTS conversations (
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
ALTER TABLE conversations ADD COLUMN IF NOT EXISTS pinned_at TIMESTAMPTZ;
|
||||
-- Approval gate for asset targets, opt-in per conversation (a chat has no task). false (default) = no gate.
|
||||
ALTER TABLE conversations ADD COLUMN IF NOT EXISTS target_gate BOOLEAN NOT NULL DEFAULT false;
|
||||
CREATE INDEX IF NOT EXISTS idx_conversations_llm_profile ON conversations(llm_profile_id) WHERE llm_profile_id IS NOT NULL;
|
||||
CREATE INDEX IF NOT EXISTS idx_conversations_pinned ON conversations(pinned_at DESC) WHERE pinned_at IS NOT NULL;
|
||||
DROP TRIGGER IF EXISTS trg_conversations_upd ON conversations;
|
||||
@@ -1119,6 +1123,55 @@ UPDATE intercept_pending SET decision_source='model'
|
||||
-- existed) | human | rule | model | system. No backfill: readers fall back to
|
||||
-- decision_source for legacy rows instead of rewriting history.
|
||||
ALTER TABLE intercept_pending ADD COLUMN IF NOT EXISTS decided_by TEXT NOT NULL DEFAULT '';
|
||||
-- Target approval requests. target_key = '<host>:<port>' of the asset target the row asks for;
|
||||
-- NULL = classic ask (and every row that predates the column). At most one open request per
|
||||
-- scope (task or conversation) and target: concurrent calls to one host share one row.
|
||||
ALTER TABLE intercept_pending ADD COLUMN IF NOT EXISTS target_key TEXT;
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_intercept_pending_target
|
||||
ON intercept_pending (COALESCE(task_id,''), COALESCE(conversation_id,0), target_key)
|
||||
WHERE status = 'pending' AND target_key IS NOT NULL;
|
||||
|
||||
-- Target pins: host + port approved by a human for one task or one conversation, with the
|
||||
-- addresses shown at approval time. Written only by the decide endpoint. No foreign keys on
|
||||
-- task_id, conversation_id and intercept_id: a pin outlives its task, its conversation and its
|
||||
-- request row as history (revoked, never deleted). status and revoked_reason carry no CHECK
|
||||
-- (validated in Go, same convention as findings.status).
|
||||
CREATE TABLE IF NOT EXISTS target_pins (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
task_id BIGINT,
|
||||
conversation_id BIGINT,
|
||||
host TEXT NOT NULL, -- normalised name, IP literal or CIDR
|
||||
port INTEGER NOT NULL DEFAULT 0, -- 0 = port not declared: covers every port of the host
|
||||
version INTEGER NOT NULL DEFAULT 1,
|
||||
pinned_ips TEXT[] NOT NULL DEFAULT '{}', -- canonical, sorted
|
||||
resolver TEXT NOT NULL DEFAULT '',
|
||||
resolved_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
status TEXT NOT NULL DEFAULT 'active', -- active | superseded | revoked
|
||||
intercept_id BIGINT, -- the request row that carried the click
|
||||
decided_by TEXT NOT NULL DEFAULT '', -- approver: subject of the session token
|
||||
decided_from TEXT NOT NULL DEFAULT '', -- remote address of the click
|
||||
-- Approved while the upstream egress proxy was set: the proxy chooses the address, so the
|
||||
-- pin covers the name only and its addresses are not compared. Stays true if the proxy is
|
||||
-- removed later; enforcing addresses again takes a new approval.
|
||||
name_only_effective BOOLEAN NOT NULL DEFAULT false,
|
||||
revoked_at TIMESTAMPTZ,
|
||||
revoked_reason TEXT NOT NULL DEFAULT '', -- operator | task_deleted | conversation_deleted
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_target_pins_active
|
||||
ON target_pins (COALESCE(task_id,0), COALESCE(conversation_id,0), host, port)
|
||||
WHERE status = 'active';
|
||||
CREATE INDEX IF NOT EXISTS idx_target_pins_scope ON target_pins (task_id, conversation_id, status);
|
||||
|
||||
-- Append-only history of the target-gate exemption list (settings key target_gate_exempt_hosts).
|
||||
CREATE TABLE IF NOT EXISTS target_gate_exemption_log (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
changed_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
changed_by TEXT NOT NULL DEFAULT '',
|
||||
before JSONB,
|
||||
after JSONB,
|
||||
digest TEXT NOT NULL DEFAULT ''
|
||||
);
|
||||
|
||||
-- =====================================================================
|
||||
-- L. Finding persistence
|
||||
|
||||
@@ -0,0 +1,553 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strconv"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TargetScopeRef names what a target pin or a target approval request is bound
|
||||
// to: one task or one conversation, never both.
|
||||
type TargetScopeRef struct {
|
||||
TaskID int64
|
||||
ConversationID int64
|
||||
}
|
||||
|
||||
func (s TargetScopeRef) check() error {
|
||||
if (s.TaskID > 0) == (s.ConversationID > 0) {
|
||||
return fmt.Errorf("target scope must name one task or one conversation, got task %d and conversation %d", s.TaskID, s.ConversationID)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// pendingScope is the scope as intercept_pending stores it: task_id is TEXT
|
||||
// there, and the column of the other kind is NULL.
|
||||
func (s TargetScopeRef) pendingScope() (taskID *string, convID *int64) {
|
||||
if s.TaskID > 0 {
|
||||
v := strconv.FormatInt(s.TaskID, 10)
|
||||
return &v, nil
|
||||
}
|
||||
return nil, &s.ConversationID
|
||||
}
|
||||
|
||||
// TargetKey is the key of an asset target inside its scope: host and port. The
|
||||
// port is always the part after the last colon, so the key stays unambiguous
|
||||
// for IPv6 literals and CIDRs.
|
||||
func TargetKey(host string, port int) string {
|
||||
return host + ":" + strconv.Itoa(port)
|
||||
}
|
||||
|
||||
// Target pin states and revocation reasons. The columns carry no CHECK; these
|
||||
// are the only values written.
|
||||
const (
|
||||
TargetPinActive = "active"
|
||||
TargetPinSuperseded = "superseded"
|
||||
TargetPinRevoked = "revoked"
|
||||
|
||||
TargetPinRevokedByOperator = "operator"
|
||||
TargetPinRevokedTaskDeleted = "task_deleted"
|
||||
TargetPinRevokedConversationDelete = "conversation_deleted"
|
||||
)
|
||||
|
||||
// TargetPin is one row of target_pins: a host and port a human approved for one
|
||||
// scope, with the addresses that were shown at approval time. Port 0 means the
|
||||
// port was not declared and covers every port of the host.
|
||||
type TargetPin struct {
|
||||
ID int64 `json:"id"`
|
||||
TaskID *int64 `json:"task_id"`
|
||||
ConversationID *int64 `json:"conversation_id"`
|
||||
Host string `json:"host"`
|
||||
Port int `json:"port"`
|
||||
Version int `json:"version"`
|
||||
PinnedIPs []string `json:"pinned_ips"`
|
||||
Resolver string `json:"resolver"`
|
||||
ResolvedAt time.Time `json:"resolved_at"`
|
||||
Status string `json:"status"`
|
||||
InterceptID *int64 `json:"intercept_id"`
|
||||
DecidedBy string `json:"decided_by"`
|
||||
DecidedFrom string `json:"decided_from"`
|
||||
// NameOnlyEffective: approved while the upstream egress proxy was set, so the
|
||||
// pin covers the name and its addresses are not compared. It stays set when
|
||||
// the proxy is removed later.
|
||||
NameOnlyEffective bool `json:"name_only_effective"`
|
||||
RevokedAt *time.Time `json:"revoked_at,omitempty"`
|
||||
RevokedReason string `json:"revoked_reason,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
const targetPinCols = `id, task_id, conversation_id, host, port, version, array_to_json(pinned_ips)::text, resolver, resolved_at, status, intercept_id, decided_by, decided_from, name_only_effective, revoked_at, revoked_reason, created_at`
|
||||
|
||||
// targetPinScope matches the expressions of uq_target_pins_active.
|
||||
const targetPinScope = `COALESCE(task_id,0)=$1 AND COALESCE(conversation_id,0)=$2`
|
||||
|
||||
func scanTargetPin(row interface{ Scan(...any) error }) (*TargetPin, error) {
|
||||
var p TargetPin
|
||||
var ips string
|
||||
if err := row.Scan(&p.ID, &p.TaskID, &p.ConversationID, &p.Host, &p.Port, &p.Version, &ips, &p.Resolver, &p.ResolvedAt,
|
||||
&p.Status, &p.InterceptID, &p.DecidedBy, &p.DecidedFrom, &p.NameOnlyEffective, &p.RevokedAt, &p.RevokedReason, &p.CreatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := json.Unmarshal([]byte(ips), &p.PinnedIPs); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &p, nil
|
||||
}
|
||||
|
||||
func queryTargetPins(q interface {
|
||||
Query(string, ...any) (*sql.Rows, error)
|
||||
}, query string, args ...any) ([]*TargetPin, error) {
|
||||
rows, err := q.Query(query, args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
out := []*TargetPin{}
|
||||
for rows.Next() {
|
||||
p, err := scanTargetPin(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, p)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// ActiveTargetPins returns the active pins that may cover the target
|
||||
// (host, port) in scope, the pin on the exact port first. A target on a declared
|
||||
// port has two candidates: the pin on that port and the pin on port 0. A target
|
||||
// whose port is not declared (0) has one: a pin on a specific port never covers
|
||||
// it. The address sets of two pins are never merged by the caller.
|
||||
func (d *DB) ActiveTargetPins(scope TargetScopeRef, host string, port int) ([]*TargetPin, error) {
|
||||
if err := scope.check(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return queryTargetPins(d, `SELECT `+targetPinCols+` FROM target_pins
|
||||
WHERE `+targetPinScope+` AND host=$3 AND port IN ($4, 0) AND status='active'
|
||||
ORDER BY port DESC`, scope.TaskID, scope.ConversationID, host, port)
|
||||
}
|
||||
|
||||
// ListTargetPins returns every pin of scope in any state, newest first.
|
||||
func (d *DB) ListTargetPins(scope TargetScopeRef) ([]*TargetPin, error) {
|
||||
if err := scope.check(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return queryTargetPins(d, `SELECT `+targetPinCols+` FROM target_pins
|
||||
WHERE `+targetPinScope+` ORDER BY id DESC`, scope.TaskID, scope.ConversationID)
|
||||
}
|
||||
|
||||
// RevokeTargetPin revokes one active pin of scope on behalf of the operator and
|
||||
// returns the pin as it is afterwards (nil when scope has no such pin). Revoking
|
||||
// a pin that is no longer active changes nothing.
|
||||
func (d *DB) RevokeTargetPin(scope TargetScopeRef, pinID int64) (*TargetPin, error) {
|
||||
if err := scope.check(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, err := d.Exec(`UPDATE target_pins SET status='revoked', revoked_at=now(), revoked_reason=$4
|
||||
WHERE `+targetPinScope+` AND id=$3 AND status='active'`, scope.TaskID, scope.ConversationID, pinID, TargetPinRevokedByOperator); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
p, err := scanTargetPin(d.QueryRow(`SELECT `+targetPinCols+` FROM target_pins
|
||||
WHERE `+targetPinScope+` AND id=$3`, scope.TaskID, scope.ConversationID, pinID))
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, nil
|
||||
}
|
||||
return p, err
|
||||
}
|
||||
|
||||
// revokeTaskPins marks the active pins of a task that is being deleted as
|
||||
// revoked. They stay as history; task ids are never reused, so a revoked pin
|
||||
// can never match again.
|
||||
func revokeTaskPins(tx *sql.Tx, taskID int64) error {
|
||||
_, err := tx.Exec(`UPDATE target_pins SET status='revoked', revoked_at=now(), revoked_reason=$2
|
||||
WHERE task_id=$1 AND status='active'`, taskID, TargetPinRevokedTaskDeleted)
|
||||
return err
|
||||
}
|
||||
|
||||
// revokeConversationPins is revokeTaskPins for deleted conversations.
|
||||
func revokeConversationPins(tx *sql.Tx, convIDs []int64) error {
|
||||
_, err := tx.Exec(`UPDATE target_pins SET status='revoked', revoked_at=now(), revoked_reason=$2
|
||||
WHERE conversation_id=ANY($1::bigint[]) AND status='active'`, convIDs, TargetPinRevokedConversationDelete)
|
||||
return err
|
||||
}
|
||||
|
||||
// settleTaskTargetRequests closes the open target requests of a task that is
|
||||
// being deleted: denied by the system, nothing pinned. Only target rows are
|
||||
// touched. (A conversation needs no counterpart: its rows cascade.)
|
||||
func settleTaskTargetRequests(tx *sql.Tx, taskID int64) error {
|
||||
_, err := tx.Exec(`UPDATE intercept_pending SET status='denied', decided_at=NOW(), decided_by='system',
|
||||
audit=CASE WHEN audit IS NULL THEN NULL ELSE audit ||
|
||||
'{"effective_action":"deny","decision_reason":"task deleted","execution_status":"not_executed"}'::jsonb END
|
||||
WHERE COALESCE(task_id,'')=$1 AND status='pending' AND target_key IS NOT NULL`, strconv.FormatInt(taskID, 10))
|
||||
return err
|
||||
}
|
||||
|
||||
// TargetRequest is a new target approval request: the intercept_pending row a
|
||||
// gated call asks for when one of its targets has no pin that covers it.
|
||||
type TargetRequest struct {
|
||||
Scope TargetScopeRef
|
||||
Host string
|
||||
Port int
|
||||
RuleID int64 // owning rule of a merged ask; 0 otherwise
|
||||
AgentName string
|
||||
ToolName string
|
||||
ToolInput []byte
|
||||
Reason string
|
||||
Audit *InterceptAudit
|
||||
}
|
||||
|
||||
// FindOrCreateTargetRequest returns the open request for (scope, host, port),
|
||||
// creating it when there is none. At most one such row is open at a time
|
||||
// (uq_intercept_pending_target), across goroutines and across instances that
|
||||
// share the database, so concurrent calls to one host share one request.
|
||||
// created reports whether this call wrote the row. id == 0 means the request
|
||||
// that was open got decided in between: the caller goes back to the pin lookup.
|
||||
func (d *DB) FindOrCreateTargetRequest(r TargetRequest) (id int64, created bool, err error) {
|
||||
if err := r.Scope.check(); err != nil {
|
||||
return 0, false, err
|
||||
}
|
||||
raw := json.RawMessage(r.ToolInput)
|
||||
if len(raw) == 0 {
|
||||
raw = json.RawMessage("{}")
|
||||
}
|
||||
var ruleID *int64
|
||||
if r.RuleID != 0 {
|
||||
ruleID = &r.RuleID
|
||||
}
|
||||
taskID, convID := r.Scope.pendingScope()
|
||||
key := TargetKey(r.Host, r.Port)
|
||||
err = d.QueryRow(`
|
||||
INSERT INTO intercept_pending(rule_id, conversation_id, task_id, agent_name, tool_name, tool_input, reason, decision_source, audit, target_key)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, 'target', $8, $9)
|
||||
ON CONFLICT (COALESCE(task_id,''), COALESCE(conversation_id,0), target_key)
|
||||
WHERE status = 'pending' AND target_key IS NOT NULL
|
||||
DO NOTHING RETURNING id`,
|
||||
ruleID, convID, taskID, r.AgentName, r.ToolName, raw, r.Reason, firstAudit([]*InterceptAudit{r.Audit}), key).Scan(&id)
|
||||
if err == nil {
|
||||
return id, true, nil
|
||||
}
|
||||
if !errors.Is(err, sql.ErrNoRows) {
|
||||
return 0, false, err
|
||||
}
|
||||
taskKey := ""
|
||||
if taskID != nil {
|
||||
taskKey = *taskID
|
||||
}
|
||||
err = d.QueryRow(`SELECT id FROM intercept_pending
|
||||
WHERE COALESCE(task_id,'')=$1 AND COALESCE(conversation_id,0)=$2 AND target_key=$3 AND status='pending'`,
|
||||
taskKey, r.Scope.ConversationID, key).Scan(&id)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return 0, false, nil
|
||||
}
|
||||
return id, false, err
|
||||
}
|
||||
|
||||
// TargetRequestKey returns the target key of an approval row: "" for a classic
|
||||
// ask and for an id that does not exist.
|
||||
func (d *DB) TargetRequestKey(id int64) (string, error) {
|
||||
var key sql.NullString
|
||||
err := d.QueryRow(`SELECT target_key FROM intercept_pending WHERE id=$1`, id).Scan(&key)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return "", nil
|
||||
}
|
||||
return key.String, err
|
||||
}
|
||||
|
||||
// ResolveTargetRequest settles an open target request with a human decision, in
|
||||
// one transaction. Allowed pins the addresses the request displayed: the active
|
||||
// pin of the same key, if any, becomes superseded and a new version holds its
|
||||
// addresses plus the displayed ones (addresses leave only by revocation).
|
||||
// Denied pins nothing. approver and remoteAddr go to the pin and to the row's
|
||||
// audit; decided_by on the row stays the actor class, human. ok is false when
|
||||
// the row is not an open target request. If the pin cannot be written the row
|
||||
// stays pending.
|
||||
func (d *DB) ResolveTargetRequest(id int64, allowed bool, approver, remoteAddr string) (pin *TargetPin, ok bool, err error) {
|
||||
tx, err := d.Begin()
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
var taskID sql.NullString
|
||||
var convID sql.NullInt64
|
||||
var raw []byte
|
||||
err = tx.QueryRow(`SELECT task_id, conversation_id, audit FROM intercept_pending
|
||||
WHERE id=$1 AND status='pending' AND target_key IS NOT NULL FOR UPDATE`, id).Scan(&taskID, &convID, &raw)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
var audit InterceptAudit
|
||||
if len(raw) > 0 {
|
||||
if err := json.Unmarshal(raw, &audit); err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
}
|
||||
status, action, reason, execution := "denied", "deny", "user rejected the target; nothing was pinned", "not_executed"
|
||||
if allowed {
|
||||
if len(audit.Targets) != 1 {
|
||||
return nil, false, fmt.Errorf("target request %d records %d targets, want 1", id, len(audit.Targets))
|
||||
}
|
||||
scope := TargetScopeRef{ConversationID: convID.Int64}
|
||||
if taskID.Valid {
|
||||
if scope.TaskID, err = strconv.ParseInt(taskID.String, 10, 64); err != nil {
|
||||
return nil, false, fmt.Errorf("target request %d: task id %q: %w", id, taskID.String, err)
|
||||
}
|
||||
}
|
||||
if pin, err = writeTargetPin(tx, scope, id, &audit.Targets[0], approver, remoteAddr); err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
status, action, reason = "allowed", "allow", "user approved the target; the displayed addresses were pinned"
|
||||
// A plain target request is host-level: no single call owns its result.
|
||||
execution = "unknown"
|
||||
if audit.MergedAsk && audit.Correlation == "exact" {
|
||||
execution = "awaiting_result"
|
||||
}
|
||||
} else if len(audit.Targets) == 1 {
|
||||
audit.Targets[0].Outcome = "denied"
|
||||
}
|
||||
fields := map[string]any{
|
||||
"effective_action": action, "decision_reason": reason, "execution_status": execution,
|
||||
"targets": audit.Targets,
|
||||
}
|
||||
if approver != "" {
|
||||
fields["approver"] = approver
|
||||
}
|
||||
if remoteAddr != "" {
|
||||
fields["approver_addr"] = remoteAddr
|
||||
}
|
||||
patch, err := json.Marshal(fields)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
if _, err := tx.Exec(`UPDATE intercept_pending SET status=$2, decided_at=NOW(), decided_by='human',
|
||||
audit=COALESCE(audit,'{}'::jsonb) || $3::jsonb WHERE id=$1`, id, status, patch); err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
return pin, true, nil
|
||||
}
|
||||
|
||||
// ErrTargetPinEmpty refuses an approval whose request displayed no address: a
|
||||
// pin never holds zero addresses, whatever the previous pin of the key holds.
|
||||
var ErrTargetPinEmpty = errors.New("the target request lists no resolved address; an approval never pins an empty address list")
|
||||
|
||||
// writeTargetPin inserts the pin an approved request creates and fills the
|
||||
// request's target with what was pinned. The only writer of target_pins rows.
|
||||
func writeTargetPin(tx *sql.Tx, scope TargetScopeRef, interceptID int64, t *InterceptTarget, approver, remoteAddr string) (*TargetPin, error) {
|
||||
if err := scope.check(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Versions count every pin the key ever had, revoked ones included, so a
|
||||
// version number names one pin of the key for good.
|
||||
var version int
|
||||
if err := tx.QueryRow(`SELECT COALESCE(max(version),0)+1 FROM target_pins
|
||||
WHERE `+targetPinScope+` AND host=$3 AND port=$4`, scope.TaskID, scope.ConversationID, t.Host, t.Port).Scan(&version); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ips := map[string]bool{}
|
||||
for _, ip := range t.ResolvedIPs {
|
||||
if ip != "" {
|
||||
ips[ip] = true
|
||||
}
|
||||
}
|
||||
if len(ips) == 0 {
|
||||
return nil, ErrTargetPinEmpty
|
||||
}
|
||||
prev, err := scanTargetPin(tx.QueryRow(`SELECT `+targetPinCols+` FROM target_pins
|
||||
WHERE `+targetPinScope+` AND host=$3 AND port=$4 AND status='active' FOR UPDATE`, scope.TaskID, scope.ConversationID, t.Host, t.Port))
|
||||
switch {
|
||||
case errors.Is(err, sql.ErrNoRows):
|
||||
case err != nil:
|
||||
return nil, err
|
||||
default:
|
||||
for _, ip := range prev.PinnedIPs {
|
||||
ips[ip] = true
|
||||
}
|
||||
if _, err := tx.Exec(`UPDATE target_pins SET status='superseded' WHERE id=$1`, prev.ID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
pinned := make([]string, 0, len(ips))
|
||||
for ip := range ips {
|
||||
pinned = append(pinned, ip)
|
||||
}
|
||||
sort.Strings(pinned)
|
||||
var taskID, convID *int64
|
||||
if scope.TaskID > 0 {
|
||||
taskID = &scope.TaskID
|
||||
} else {
|
||||
convID = &scope.ConversationID
|
||||
}
|
||||
resolvedAt := t.ResolvedAt
|
||||
if resolvedAt.IsZero() {
|
||||
resolvedAt = time.Now().UTC()
|
||||
}
|
||||
pin, err := scanTargetPin(tx.QueryRow(`
|
||||
INSERT INTO target_pins(task_id, conversation_id, host, port, version, pinned_ips, resolver, resolved_at, intercept_id, decided_by, decided_from, name_only_effective)
|
||||
VALUES ($1, $2, $3, $4, $5, $6::text[], $7, $8, $9, $10, $11, $12)
|
||||
RETURNING `+targetPinCols,
|
||||
taskID, convID, t.Host, t.Port, version, pinned, t.Resolver, resolvedAt, interceptID, approver, remoteAddr, t.NameOnlyEffective))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
t.Outcome, t.PinID, t.PinVersion, t.PinnedIPs = "pinned", pin.ID, pin.Version, pin.PinnedIPs
|
||||
return pin, nil
|
||||
}
|
||||
|
||||
// TargetDecision is an outcome of the target gate that no human took: a block,
|
||||
// or the first use of an exemption or of a pin version.
|
||||
type TargetDecision struct {
|
||||
Scope TargetScopeRef
|
||||
AgentName string
|
||||
ToolName string
|
||||
ToolInput []byte
|
||||
Status string // allowed | denied
|
||||
Reason string
|
||||
Audit *InterceptAudit
|
||||
}
|
||||
|
||||
// LogTargetDecision records a target-gate outcome as an already-decided row
|
||||
// (decided_by = system, decision_source = target). It opens no request and
|
||||
// never pins.
|
||||
func (d *DB) LogTargetDecision(r TargetDecision) (int64, error) {
|
||||
if err := r.Scope.check(); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
raw := json.RawMessage(r.ToolInput)
|
||||
if len(raw) == 0 {
|
||||
raw = json.RawMessage("{}")
|
||||
}
|
||||
taskID, convID := r.Scope.pendingScope()
|
||||
var id int64
|
||||
err := d.QueryRow(`
|
||||
INSERT INTO intercept_pending(conversation_id, task_id, agent_name, tool_name, tool_input, status, reason, decided_at, decision_source, audit, decided_by)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, NOW(), 'target', $8, 'system') RETURNING id`,
|
||||
convID, taskID, r.AgentName, r.ToolName, raw, r.Status, r.Reason, firstAudit([]*InterceptAudit{r.Audit})).Scan(&id)
|
||||
return id, err
|
||||
}
|
||||
|
||||
// SettingTargetExemptHosts is the settings key of the stored target-gate
|
||||
// exemption list: a JSON array of TargetExemption.
|
||||
const SettingTargetExemptHosts = "target_gate_exempt_hosts"
|
||||
|
||||
// TargetExemption is one entry of the target-gate exemption list: an exact
|
||||
// host and port that needs no approval and no pin. It is configuration, not an
|
||||
// approval.
|
||||
type TargetExemption struct {
|
||||
Host string `json:"host"`
|
||||
Port int `json:"port"`
|
||||
Note string `json:"note,omitempty"`
|
||||
}
|
||||
|
||||
// TargetExemptions returns the stored exemption list and its raw settings
|
||||
// value ("" when the list was never stored).
|
||||
func (d *DB) TargetExemptions() (list []TargetExemption, raw string, err error) {
|
||||
raw, ok, err := d.GetSetting(SettingTargetExemptHosts)
|
||||
if err != nil || !ok {
|
||||
return nil, "", err
|
||||
}
|
||||
if err := json.Unmarshal([]byte(raw), &list); err != nil {
|
||||
return nil, raw, fmt.Errorf("stored target-gate exemption list: %w", err)
|
||||
}
|
||||
return list, raw, nil
|
||||
}
|
||||
|
||||
// ReplaceTargetExemptions stores list as the exemption list and appends the
|
||||
// change to target_gate_exemption_log, in one transaction. digest identifies
|
||||
// the new list in the log and in the audit of the calls that use it.
|
||||
func (d *DB) ReplaceTargetExemptions(list []TargetExemption, changedBy, digest string) error {
|
||||
if list == nil {
|
||||
list = []TargetExemption{}
|
||||
}
|
||||
after, err := json.Marshal(list)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tx, err := d.Begin()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
var before *string
|
||||
var stored string
|
||||
switch err := tx.QueryRow(`SELECT value FROM settings WHERE key=$1 FOR UPDATE`, SettingTargetExemptHosts).Scan(&stored); {
|
||||
case errors.Is(err, sql.ErrNoRows):
|
||||
case err != nil:
|
||||
return err
|
||||
case json.Valid([]byte(stored)):
|
||||
before = &stored
|
||||
}
|
||||
if _, err := tx.Exec(`
|
||||
INSERT INTO settings(key, value) VALUES ($1, $2)
|
||||
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = now()`, SettingTargetExemptHosts, string(after)); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := tx.Exec(`INSERT INTO target_gate_exemption_log(changed_by, before, after, digest)
|
||||
VALUES ($1, $2::jsonb, $3::jsonb, $4)`, changedBy, before, string(after), digest); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
// TargetExemptionChange is one row of target_gate_exemption_log.
|
||||
type TargetExemptionChange struct {
|
||||
ID int64 `json:"id"`
|
||||
ChangedAt time.Time `json:"changed_at"`
|
||||
ChangedBy string `json:"changed_by"`
|
||||
Before json.RawMessage `json:"before"`
|
||||
After json.RawMessage `json:"after"`
|
||||
Digest string `json:"digest"`
|
||||
}
|
||||
|
||||
// ListTargetExemptionLog returns the most recent changes of the exemption
|
||||
// list, newest first.
|
||||
func (d *DB) ListTargetExemptionLog(limit int) ([]TargetExemptionChange, error) {
|
||||
rows, err := d.Query(`SELECT id, changed_at, changed_by, before, after, digest
|
||||
FROM target_gate_exemption_log ORDER BY id DESC LIMIT $1`, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
out := []TargetExemptionChange{}
|
||||
for rows.Next() {
|
||||
var c TargetExemptionChange
|
||||
var before, after []byte
|
||||
if err := rows.Scan(&c.ID, &c.ChangedAt, &c.ChangedBy, &before, &after, &c.Digest); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
c.Before, c.After = before, after
|
||||
out = append(out, c)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// seedTargetGateExemptions stores the initial exemption list once: the package
|
||||
// sources of the runtime image, so that a package install does not ask for an
|
||||
// approval. Gated by a settings flag like the rule seeds: the list is owned by
|
||||
// the operator afterwards, and an entry they remove stays removed.
|
||||
func (d *DB) seedTargetGateExemptions() error {
|
||||
if v, _, _ := d.GetSetting("target_gate_exempt_seed_v1"); v == "done" {
|
||||
return nil
|
||||
}
|
||||
raw, err := json.Marshal([]TargetExemption{
|
||||
{Host: "pypi.org", Port: 443, Note: "[built-in] Python package index"},
|
||||
{Host: "files.pythonhosted.org", Port: 443, Note: "[built-in] Python package files"},
|
||||
{Host: "registry.npmjs.org", Port: 443, Note: "[built-in] npm registry"},
|
||||
{Host: "deb.debian.org", Port: 80, Note: "[built-in] Debian packages"},
|
||||
{Host: "deb.debian.org", Port: 443, Note: "[built-in] Debian packages"},
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := d.InsertSettingIfAbsent(SettingTargetExemptHosts, string(raw)); err != nil {
|
||||
return err
|
||||
}
|
||||
return d.SetSetting("target_gate_exempt_seed_v1", "done")
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -766,6 +766,13 @@ WHERE archive.id=$1 FOR UPDATE OF archive,task`, archiveID).Scan(&taskID, &expID
|
||||
if !errors.Is(err, sql.ErrNoRows) {
|
||||
return err
|
||||
}
|
||||
// As in DeleteTaskCascadePrepared: pins stay as revoked history.
|
||||
if err := revokeTaskPins(tx, taskID); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := settleTaskTargetRequests(tx, taskID); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := tx.Exec(`DELETE FROM tasks WHERE id=$1 AND archived_at IS NOT NULL`, taskID); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
+24
-7
@@ -52,6 +52,9 @@ type Task struct {
|
||||
// PlanQuietMode gates the planner quiet-mode (#86): edge-triggered rounds
|
||||
// are skipped while work is in flight and the open-goal set is unchanged.
|
||||
PlanQuietMode bool `json:"plan_quiet_mode"`
|
||||
// TargetGate is the per-task opt-in of the approval gate for asset targets.
|
||||
// false (default) = no gate, behaviour unchanged.
|
||||
TargetGate bool `json:"target_gate"`
|
||||
// CoverageEnabled is the master switch of the "asset coverage" feature (default true). When false:
|
||||
// do not compute/show test coverage, do not auto-accumulate task_scope (source=auto), do not open
|
||||
// add_task_scope/list_untested_assets to the agent, and do not inject the coverage block into the
|
||||
@@ -173,6 +176,8 @@ type TaskCreateOptions struct {
|
||||
// chain instead of the single active head. nil = off (default), preserving
|
||||
// the ordered-failover semantics.
|
||||
LLMRotate *bool
|
||||
// TargetGate opts the task into the approval gate for asset targets; nil = off.
|
||||
TargetGate *bool
|
||||
}
|
||||
|
||||
// CreateTaskWithOptions creates an exploration, task, direct source relations,
|
||||
@@ -251,10 +256,11 @@ VALUES ($1, 'fact', $2, 0, 'origin', 'system')`, expID, string(originPayload));
|
||||
if opts.LLMRotate != nil {
|
||||
rotate = *opts.LLMRotate
|
||||
}
|
||||
t.TargetGate = opts.TargetGate != nil && *opts.TargetGate
|
||||
if err := tx.QueryRow(`
|
||||
INSERT INTO tasks(name, category_id, description, goal, exploration_id, llm_profile_id, active_llm_profile_id, timeout_seconds, plan_heartbeat_seconds, plan_quiet_mode, coverage_enabled, llm_rotate)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$6,$7,$8,$9,$10,$11)
|
||||
RETURNING id, status, paused, created_at`, opts.Name, opts.CategoryID, description, goal, expID, active, opts.TimeoutSeconds, opts.PlanHeartbeatSeconds, quietMode, coverageEnabled, rotate).Scan(&t.ID, &t.Status, &t.Paused, &t.CreatedAt); err != nil {
|
||||
INSERT INTO tasks(name, category_id, description, goal, exploration_id, llm_profile_id, active_llm_profile_id, timeout_seconds, plan_heartbeat_seconds, plan_quiet_mode, coverage_enabled, llm_rotate, target_gate)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$6,$7,$8,$9,$10,$11,$12)
|
||||
RETURNING id, status, paused, created_at`, opts.Name, opts.CategoryID, description, goal, expID, active, opts.TimeoutSeconds, opts.PlanHeartbeatSeconds, quietMode, coverageEnabled, rotate, t.TargetGate).Scan(&t.ID, &t.Status, &t.Paused, &t.CreatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := insertTaskRelations(tx, t.ID, opts.SourceTaskIDs); err != nil {
|
||||
@@ -370,11 +376,11 @@ func insertTaskLLMProfiles(tx *sql.Tx, taskID int64, profileIDs []int64) error {
|
||||
|
||||
const taskCols = `id, COALESCE(name,''), category_id,
|
||||
COALESCE((SELECT category.name FROM task_categories category WHERE category.id=tasks.category_id),''),
|
||||
description, goal, exploration_id, status, paused, queued, queued_at, COALESCE(queue_mode,''), llm_profile_id, active_llm_profile_id, COALESCE(parent_ref,''), pinned_at, created_at, completed_at, COALESCE(timeout_seconds,0), COALESCE(plan_heartbeat_seconds,300), COALESCE(plan_quiet_mode,true), COALESCE(coverage_enabled,true), COALESCE(llm_rotate,false), first_run_at, deadline_at`
|
||||
description, goal, exploration_id, status, paused, queued, queued_at, COALESCE(queue_mode,''), llm_profile_id, active_llm_profile_id, COALESCE(parent_ref,''), pinned_at, created_at, completed_at, COALESCE(timeout_seconds,0), COALESCE(plan_heartbeat_seconds,300), COALESCE(plan_quiet_mode,true), COALESCE(coverage_enabled,true), COALESCE(llm_rotate,false), first_run_at, deadline_at, target_gate`
|
||||
|
||||
func scanTask(sc interface{ Scan(...any) error }) (*Task, error) {
|
||||
var t Task
|
||||
if err := sc.Scan(&t.ID, &t.Name, &t.CategoryID, &t.CategoryName, &t.Description, &t.Goal, &t.ExplorationID, &t.Status, &t.Paused, &t.Queued, &t.QueuedAt, &t.QueueMode, &t.LLMProfileID, &t.ActiveLLMProfileID, &t.ParentRef, &t.PinnedAt, &t.CreatedAt, &t.CompletedAt, &t.TimeoutSeconds, &t.PlanHeartbeatSeconds, &t.PlanQuietMode, &t.CoverageEnabled, &t.LLMRotate, &t.FirstRunAt, &t.DeadlineAt); err != nil {
|
||||
if err := sc.Scan(&t.ID, &t.Name, &t.CategoryID, &t.CategoryName, &t.Description, &t.Goal, &t.ExplorationID, &t.Status, &t.Paused, &t.Queued, &t.QueuedAt, &t.QueueMode, &t.LLMProfileID, &t.ActiveLLMProfileID, &t.ParentRef, &t.PinnedAt, &t.CreatedAt, &t.CompletedAt, &t.TimeoutSeconds, &t.PlanHeartbeatSeconds, &t.PlanQuietMode, &t.CoverageEnabled, &t.LLMRotate, &t.FirstRunAt, &t.DeadlineAt, &t.TargetGate); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
t.Pinned = t.PinnedAt != nil
|
||||
@@ -420,6 +426,8 @@ type TaskPatch struct {
|
||||
Pinned *bool
|
||||
// PlanQuietMode flips the per-task quiet-mode switch (#86); nil = leave unchanged.
|
||||
PlanQuietMode *bool
|
||||
// TargetGate flips the per-task target-gate opt-in; nil = leave unchanged.
|
||||
TargetGate *bool
|
||||
}
|
||||
|
||||
// UpdateTask applies a partial task name/pin mutation and returns the updated row.
|
||||
@@ -432,9 +440,10 @@ func (d *DB) UpdateTask(id int64, patch TaskPatch) (*Task, error) {
|
||||
WHEN $4::boolean THEN COALESCE(pinned_at, now())
|
||||
ELSE NULL
|
||||
END,
|
||||
plan_quiet_mode = CASE WHEN $5::boolean THEN $6 ELSE plan_quiet_mode END
|
||||
plan_quiet_mode = CASE WHEN $5::boolean THEN $6 ELSE plan_quiet_mode END,
|
||||
target_gate = CASE WHEN $7::boolean THEN $8 ELSE target_gate END
|
||||
WHERE id=$1 AND deleted_at IS NULL
|
||||
RETURNING `+taskCols, id, patch.Name != nil, patch.Name, patch.Pinned, patch.PlanQuietMode != nil, patch.PlanQuietMode))
|
||||
RETURNING `+taskCols, id, patch.Name != nil, patch.Name, patch.Pinned, patch.PlanQuietMode != nil, patch.PlanQuietMode, patch.TargetGate != nil, patch.TargetGate))
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
@@ -673,6 +682,14 @@ DELETE FROM assets a USING deletable d WHERE a.id=d.id`, id, expID)
|
||||
}
|
||||
// llm_usage (the token metering ledger) is intentionally NOT deleted with the
|
||||
// task — it is kept as historical accounting even after the task is gone.
|
||||
// Target pins and approval rows are kept as history too: the pins are revoked,
|
||||
// the open target requests settled, in this transaction.
|
||||
if err := revokeTaskPins(tx, id); err != nil {
|
||||
return result, err
|
||||
}
|
||||
if err := settleTaskTargetRequests(tx, id); err != nil {
|
||||
return result, err
|
||||
}
|
||||
if _, err := tx.Exec(`DELETE FROM tasks WHERE id=$1`, id); err != nil {
|
||||
return result, err
|
||||
}
|
||||
|
||||
+129
-26
@@ -10,7 +10,11 @@ package guard
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
@@ -35,6 +39,7 @@ type Guard struct {
|
||||
reg *hook.Registry
|
||||
interceptor *intercept.Interceptor // optional; nil disables user-configured rules
|
||||
targetOnly bool // planner / main agent: target gate only, no rules, no judge, no audit ring
|
||||
gate targetGate // the target gate; nil without an interceptor
|
||||
}
|
||||
|
||||
// New creates a Guard without user-configured intercept rules (used for pentest
|
||||
@@ -50,18 +55,28 @@ func NewWithInterceptor(ic *intercept.Interceptor) *Guard { return newGuard(ic)
|
||||
// ring (which keeps the worker history of the task's main Guard).
|
||||
func NewTargetOnly(ic *intercept.Interceptor) *Guard {
|
||||
g := &Guard{attrib: map[string]int{}, interceptor: ic, targetOnly: true}
|
||||
g.setGate(ic)
|
||||
g.reg = hook.NewRegistry().On(hook.PreToolUse, g.preToolUse)
|
||||
return g
|
||||
}
|
||||
|
||||
func newGuard(ic *intercept.Interceptor) *Guard {
|
||||
g := &Guard{attrib: map[string]int{}, interceptor: ic}
|
||||
g.setGate(ic)
|
||||
g.reg = hook.NewRegistry().
|
||||
On(hook.PreToolUse, g.preToolUse).
|
||||
On(hook.PostToolUse, g.postToolUse)
|
||||
return g
|
||||
}
|
||||
|
||||
// setGate wires the interceptor in as the target gate. A nil interceptor leaves
|
||||
// the gate unset rather than set to an interface that holds a nil pointer.
|
||||
func (g *Guard) setGate(ic *intercept.Interceptor) {
|
||||
if ic != nil {
|
||||
g.gate = ic
|
||||
}
|
||||
}
|
||||
|
||||
// Hooks returns the hook registry to attach to an agent session.
|
||||
func (g *Guard) Hooks() *hook.Registry { return g.reg }
|
||||
|
||||
@@ -93,45 +108,68 @@ func (g *Guard) preToolUse(ctx context.Context, ev hook.Event) hook.Result {
|
||||
return g.applyIntercept(ctx, ev)
|
||||
}
|
||||
|
||||
// targetGate is the seam of the approval gate for asset targets. The gate is not
|
||||
// wired in yet: every call is allowed, and nothing is extracted, resolved, read
|
||||
// or written.
|
||||
func (g *Guard) targetGate(_ context.Context, _ hook.Event) hook.Result {
|
||||
return hook.Result{}
|
||||
// targetGate is the approval gate for asset targets, as the guard uses it.
|
||||
// *intercept.Interceptor implements it.
|
||||
type targetGate interface {
|
||||
CheckTargets(ctx context.Context, scope intercept.TargetScope, toolName string, input []byte) ([]intercept.TargetRequest, intercept.TargetResult)
|
||||
RequestTargets(ctx context.Context, scope intercept.TargetScope, toolName string, input []byte, targets []intercept.TargetRequest, ask *intercept.Decision) intercept.TargetResult
|
||||
}
|
||||
|
||||
// applyIntercept evaluates user-configured intercept rules against the tool call.
|
||||
// Both rules and the fallback judge receive the complete tool input.
|
||||
// targetPasses bounds the request-and-recheck loop of one call: 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 targetPasses = 3
|
||||
|
||||
// gateScope returns the target-gate scope of the session, and whether its gate
|
||||
// is on. With the gate off (the default) no call reaches the gate.
|
||||
func (g *Guard) gateScope(ctx context.Context) (intercept.TargetScope, bool) {
|
||||
scope, ok := intercept.TargetScopeFromContext(ctx)
|
||||
return scope, ok && g.gate != nil && scope.Enabled != nil && scope.Enabled()
|
||||
}
|
||||
|
||||
// applyIntercept evaluates user-configured intercept rules against the tool call,
|
||||
// then the target gate. Both rules and the fallback judge receive the complete
|
||||
// tool input.
|
||||
//
|
||||
// The rules and the judge apply to the enabled tools of a full guard. The
|
||||
// target gate applies to every tool call of a session whose gate is on: a deny
|
||||
// blocks first, before any target request exists; an allow by a rule or by the
|
||||
// judge does not satisfy the gate; an ask and an unpinned target share one
|
||||
// approval request.
|
||||
func (g *Guard) applyIntercept(ctx context.Context, ev hook.Event) hook.Result {
|
||||
if g.targetOnly {
|
||||
return g.targetGate(ctx, ev)
|
||||
}
|
||||
if g.interceptor == nil {
|
||||
return hook.Result{}
|
||||
}
|
||||
if !g.interceptor.IsToolEnabled(ev.ToolName) {
|
||||
scope, gated := g.gateScope(ctx)
|
||||
rules := !g.targetOnly && g.interceptor != nil && g.interceptor.IsToolEnabled(ev.ToolName)
|
||||
if !rules && !gated {
|
||||
return hook.Result{}
|
||||
}
|
||||
ctx = intercept.WithCall(ctx, ev.ToolName, ev.Input)
|
||||
dec, matched := g.interceptor.Match(ev.ToolName, ev.Input)
|
||||
if !matched {
|
||||
// No rule matched. Ask the LLM fallback judge (if enabled); when it is off
|
||||
// or unwired, keep current behavior and allow.
|
||||
d, judged := g.interceptor.Judge(ctx, ev.ToolName, ev.Input)
|
||||
if !judged {
|
||||
return hook.Result{}
|
||||
var dec intercept.Decision
|
||||
if rules {
|
||||
var matched bool
|
||||
dec, matched = g.interceptor.Match(ev.ToolName, ev.Input)
|
||||
if !matched {
|
||||
// No rule matched. Ask the LLM fallback judge (if enabled); when it is off
|
||||
// or unwired, keep current behavior and allow.
|
||||
dec, _ = g.interceptor.Judge(ctx, ev.ToolName, ev.Input)
|
||||
}
|
||||
dec = d
|
||||
}
|
||||
switch dec.Action {
|
||||
case "deny":
|
||||
if dec.Action == "deny" {
|
||||
// Note: a deny hit does not block for approval; it directly logs a denied entry (visible on the history / task interception pages).
|
||||
g.interceptor.Log(ctx, intercept.ConvIDFromContext(ctx), dec, ev.ToolName, ev.Input, "denied")
|
||||
return g.block(ev.ToolName, systemBlockMessage(dec.Message), "")
|
||||
}
|
||||
var unpinned []intercept.TargetRequest
|
||||
if gated {
|
||||
var res intercept.TargetResult
|
||||
if unpinned, res = g.gate.CheckTargets(ctx, scope, ev.ToolName, ev.Input); res.Code != "" {
|
||||
return g.block(ev.ToolName, TargetBlockMessage(res), "")
|
||||
}
|
||||
}
|
||||
var ask *intercept.Decision
|
||||
switch dec.Action {
|
||||
case "allow":
|
||||
// Record explicit rule and model approvals so review details remain auditable.
|
||||
g.interceptor.Log(ctx, intercept.ConvIDFromContext(ctx), dec, ev.ToolName, ev.Input, "allowed")
|
||||
return hook.Result{}
|
||||
case "ask":
|
||||
// If the worker context is already cancelled (task stopped / killed), block
|
||||
// immediately without creating a pending record — avoids orphaned DB entries
|
||||
@@ -139,15 +177,80 @@ func (g *Guard) applyIntercept(ctx context.Context, ev hook.Event) hook.Result {
|
||||
if ctx.Err() != nil {
|
||||
return g.block(ev.ToolName, systemBlockMessage("work cancelled; platform safety controls blocked execution"), "")
|
||||
}
|
||||
ask = &dec
|
||||
}
|
||||
if len(unpinned) > 0 {
|
||||
res, merged := g.requestTargets(ctx, scope, ev, unpinned, ask)
|
||||
if res.Code != "" {
|
||||
return g.block(ev.ToolName, TargetBlockMessage(res), "")
|
||||
}
|
||||
if merged {
|
||||
// The approval of the target request answered the ask as well.
|
||||
ask = nil
|
||||
}
|
||||
}
|
||||
if ask != nil {
|
||||
convID := intercept.ConvIDFromContext(ctx)
|
||||
if !g.interceptor.HandleAsk(ctx, convID, dec, ev.ToolName, ev.Input) {
|
||||
return g.block(ev.ToolName, systemBlockMessage("manual approval not granted (user rejected or approval timed out)"), "")
|
||||
}
|
||||
return hook.Result{}
|
||||
}
|
||||
return hook.Result{}
|
||||
}
|
||||
|
||||
// requestTargets asks for the approval of the targets no pin covers, waits for
|
||||
// the decisions, and checks the call again: it runs only if every target is
|
||||
// covered then. A code in the result blocks the call. merged reports that the
|
||||
// ask was carried by a request this call created, so it needs no prompt of its
|
||||
// own.
|
||||
func (g *Guard) requestTargets(ctx context.Context, scope intercept.TargetScope, ev hook.Event, unpinned []intercept.TargetRequest, ask *intercept.Decision) (res intercept.TargetResult, merged bool) {
|
||||
for pass := 0; pass < targetPasses; pass++ {
|
||||
if res = g.gate.RequestTargets(ctx, scope, ev.ToolName, ev.Input, unpinned, ask); res.Code != "" {
|
||||
return res, merged
|
||||
}
|
||||
if res.AskMerged {
|
||||
merged, ask = true, nil
|
||||
}
|
||||
if unpinned, res = g.gate.CheckTargets(ctx, scope, ev.ToolName, ev.Input); res.Code != "" || len(unpinned) == 0 {
|
||||
return res, merged
|
||||
}
|
||||
}
|
||||
t := unpinned[0]
|
||||
return intercept.TargetResult{Code: intercept.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", targetPasses)}, merged
|
||||
}
|
||||
|
||||
// targetGuidance tells the model what a target block means for its next step.
|
||||
var targetGuidance = map[string]string{
|
||||
intercept.TargetApprovalPending: "The call was not executed. An approval by the operator is needed and may still arrive: retry the same call later. " +
|
||||
"Do not rewrite the command, switch tools or substitute an IP address.",
|
||||
intercept.TargetDenied: "The call was not executed. The operator did not approve this target: do not retry it and do not reach it another way.",
|
||||
intercept.TargetResolveFailed: "The call was not executed and no approval was requested. " +
|
||||
"This is a resolver failure on the platform side, not a block by the target. " +
|
||||
"Do not rewrite the command, switch tools or substitute an IP address. Retry the same call later, or report the failure.",
|
||||
intercept.TargetRuleBlocked: "The call was not executed and no approval was requested. " +
|
||||
"The operator's rules exclude this target: do not reach it another way.",
|
||||
intercept.TargetTooMany: "The call was not executed and no approval was requested. Name fewer new targets per call.",
|
||||
intercept.TargetGateUnavailable: "The call was not executed. This is a failure on the platform side, not a block by the target. " +
|
||||
"Retry the same call later, or report the failure.",
|
||||
}
|
||||
|
||||
// TargetBlockMessage is systemBlockMessage for the target gate: the block code,
|
||||
// the reason and the target, then what to do about it. A target block is not
|
||||
// always final (an approval can still arrive, a lookup can succeed later), so
|
||||
// it does not end with "This operation is forbidden."
|
||||
func TargetBlockMessage(res intercept.TargetResult) string {
|
||||
reason := res.Reason
|
||||
if res.Host != "" && !strings.Contains(reason, res.Host) {
|
||||
target := res.Host
|
||||
if res.Port != 0 {
|
||||
target = net.JoinHostPort(res.Host, strconv.Itoa(res.Port))
|
||||
}
|
||||
reason = "target " + target + ": " + reason
|
||||
}
|
||||
return "[ARTEX platform control, not target defense] " + res.Code + ": " + reason + ". " + targetGuidance[res.Code]
|
||||
}
|
||||
|
||||
// systemBlockMessage frames an intercept block as an ARTEX platform-governance
|
||||
// decision so the agent does not mistake it for a target-side defense.
|
||||
//
|
||||
|
||||
+5
-4
@@ -37,10 +37,11 @@ func TestPreToolUsePassthrough(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// The target-only guard (planner, main agent) runs the target-gate seam and
|
||||
// nothing else: no intercept rule, no fallback judge, no audit-ring entry. Its
|
||||
// interceptor has no database behind it, so any read or write through it would
|
||||
// panic — passing proves the seam allows every call without touching the store.
|
||||
// The target-only guard (planner, main agent) runs the target gate and nothing
|
||||
// else: no intercept rule, no fallback judge, no audit-ring entry. Here the gate
|
||||
// is off (no scope, or a scope without the flag). Its interceptor has no database
|
||||
// behind it, so any read or write through it would panic — passing proves that
|
||||
// with the gate off every call is allowed without touching the store.
|
||||
func TestTargetOnlyGuardSkipsRules(t *testing.T) {
|
||||
calls := []struct {
|
||||
tool string
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,14 @@
|
||||
package guard
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/Autumn-27/artex/db"
|
||||
)
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
// Own database per package (#80), as in db, agent, server, evidence and intercept.
|
||||
db.SuffixTestDatabase("guard")
|
||||
os.Exit(m.Run())
|
||||
}
|
||||
+67
-13
@@ -87,11 +87,14 @@ const DefaultTargetWaitBound = 300 * time.Second
|
||||
// (TaskID) or one conversation (ConversationID), never both. Enabled reports the
|
||||
// scope's opt-in flag and is read on every gated call; nil means the gate is off.
|
||||
// WaitBound caps the wait for a human decision; 0 means never wait.
|
||||
// Interceptor is the one that owns the gate of this session, so that code
|
||||
// holding only a context (an HTTP redirect callback) can reach it.
|
||||
type TargetScope struct {
|
||||
TaskID string
|
||||
ConversationID int64
|
||||
Enabled func() bool
|
||||
WaitBound time.Duration
|
||||
Interceptor *Interceptor
|
||||
}
|
||||
|
||||
// WithTargetScope returns a child context carrying the target-gate scope of the
|
||||
@@ -112,36 +115,52 @@ type compiledRule struct {
|
||||
re *regexp.Regexp
|
||||
}
|
||||
|
||||
// pendingManager tracks in-flight "ask" requests via per-request channels.
|
||||
// pendingManager tracks in-flight approval requests via per-waiter channels. A
|
||||
// classic ask has one waiter; a target request can have several (every call
|
||||
// that names the same host waits on the same row).
|
||||
type pendingManager struct {
|
||||
mu sync.Mutex
|
||||
ch map[int64]chan bool
|
||||
ch map[int64][]chan bool
|
||||
}
|
||||
|
||||
func newPendingManager() *pendingManager { return &pendingManager{ch: map[int64]chan bool{}} }
|
||||
func newPendingManager() *pendingManager { return &pendingManager{ch: map[int64][]chan bool{}} }
|
||||
|
||||
// add registers one more waiter for id and returns its channel.
|
||||
func (p *pendingManager) add(id int64) chan bool {
|
||||
ch := make(chan bool, 1)
|
||||
p.mu.Lock()
|
||||
p.ch[id] = ch
|
||||
p.ch[id] = append(p.ch[id], ch)
|
||||
p.mu.Unlock()
|
||||
return ch
|
||||
}
|
||||
|
||||
// resolve wakes every waiter of id with the decision.
|
||||
func (p *pendingManager) resolve(id int64, allowed bool) {
|
||||
p.mu.Lock()
|
||||
ch, ok := p.ch[id]
|
||||
waiters := p.ch[id]
|
||||
delete(p.ch, id)
|
||||
p.mu.Unlock()
|
||||
if ok {
|
||||
for _, ch := range waiters {
|
||||
ch <- allowed
|
||||
}
|
||||
}
|
||||
|
||||
func (p *pendingManager) remove(id int64) {
|
||||
// remove takes one waiter's channel out; the other waiters of id stay.
|
||||
func (p *pendingManager) remove(id int64, ch chan bool) {
|
||||
p.mu.Lock()
|
||||
delete(p.ch, id)
|
||||
p.mu.Unlock()
|
||||
defer p.mu.Unlock()
|
||||
waiters := p.ch[id]
|
||||
for n, w := range waiters {
|
||||
if w == ch {
|
||||
waiters = append(waiters[:n:n], waiters[n+1:]...)
|
||||
break
|
||||
}
|
||||
}
|
||||
if len(waiters) == 0 {
|
||||
delete(p.ch, id)
|
||||
return
|
||||
}
|
||||
p.ch[id] = waiters
|
||||
}
|
||||
|
||||
// Reviewer runs the LLM fallback judge for one tool call and returns its verdict
|
||||
@@ -159,6 +178,27 @@ type Interceptor struct {
|
||||
enabledTools map[string]bool // nil means not loaded yet
|
||||
pending *pendingManager
|
||||
reviewer Reviewer // nil = LLM fallback judge not wired
|
||||
// upstreamProxy reports whether target traffic leaves through an upstream
|
||||
// egress proxy (nil = no). Injected by the server layer.
|
||||
upstreamProxy func() bool
|
||||
// derivedExempt lists the endpoints of the platform's own services (nil =
|
||||
// none). Injected by the server layer. See SetDerivedExemptions.
|
||||
derivedExempt func() []string
|
||||
// targets is what the target gate reads and writes; resolve resolves the
|
||||
// names it checks. firstUse holds the exemptions and pin versions whose first
|
||||
// use this process has logged.
|
||||
targets targetStore
|
||||
resolve *resolveCache
|
||||
firstUse sync.Map
|
||||
}
|
||||
|
||||
// SetUpstreamProxyCheck installs the function that reports whether an upstream
|
||||
// egress proxy is configured. Behind one the proxy chooses the address it
|
||||
// connects to, so a target approval given while it is set is name-only.
|
||||
func (i *Interceptor) SetUpstreamProxyCheck(f func() bool) {
|
||||
i.mu.Lock()
|
||||
i.upstreamProxy = f
|
||||
i.mu.Unlock()
|
||||
}
|
||||
|
||||
// SetReviewer installs the LLM fallback judge callback. Passing nil disables it.
|
||||
@@ -179,7 +219,8 @@ var defaultEnabledTools = []string{
|
||||
// New creates an Interceptor backed by d. The rule cache is lazy-loaded on
|
||||
// first use.
|
||||
func New(d *db.DB) *Interceptor {
|
||||
return &Interceptor{db: d, pending: newPendingManager()}
|
||||
return &Interceptor{db: d, pending: newPendingManager(),
|
||||
targets: dbTargetStore{d}, resolve: newResolveCache(systemResolver{})}
|
||||
}
|
||||
|
||||
// Invalidate clears the in-memory rule cache and the enabled-tools cache.
|
||||
@@ -633,7 +674,7 @@ func (i *Interceptor) HandleAsk(ctx context.Context, convID int64, dec Decision,
|
||||
|
||||
i.bindResult(ctx, pendingID, audit)
|
||||
ch := i.pending.add(pendingID)
|
||||
defer i.pending.remove(pendingID)
|
||||
defer i.pending.remove(pendingID, ch)
|
||||
// Polling clients can decide after INSERT commits but before the channel is
|
||||
// registered. Re-read after registration so that decision cannot be lost;
|
||||
// later decisions will be delivered through ch.
|
||||
@@ -707,7 +748,11 @@ func (i *Interceptor) HandleAsk(ctx context.Context, convID int64, dec Decision,
|
||||
var ErrAlreadyDecided = errors.New("approval already decided or does not exist; please refresh the list")
|
||||
|
||||
// Decide resolves a pending request. Called by the HTTP decide endpoint.
|
||||
func (i *Interceptor) Decide(pendingID int64, allowed bool) error {
|
||||
// approver (subject of the session token) and remoteAddr identify the human
|
||||
// behind the decision; they are recorded in the row's audit. On a target
|
||||
// request "allowed" also pins the addresses the request displayed, and both
|
||||
// values go to the pin: a human decision is the only thing that creates a pin.
|
||||
func (i *Interceptor) Decide(pendingID int64, allowed bool, approver, remoteAddr string) error {
|
||||
status := "denied"
|
||||
if allowed {
|
||||
status = "allowed"
|
||||
@@ -716,7 +761,16 @@ func (i *Interceptor) Decide(pendingID int64, allowed bool) error {
|
||||
if allowed {
|
||||
action, reason = "allow", "user approved execution"
|
||||
}
|
||||
resolved, err := i.db.ResolveIntercept(pendingID, status, action, reason, "human")
|
||||
targetKey, err := i.db.TargetRequestKey(pendingID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var resolved bool
|
||||
if targetKey != "" {
|
||||
_, resolved, err = i.db.ResolveTargetRequest(pendingID, allowed, approver, remoteAddr)
|
||||
} else {
|
||||
resolved, err = i.db.ResolveInterceptBy(pendingID, status, action, reason, "human", approver, remoteAddr)
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
package intercept
|
||||
|
||||
import (
|
||||
"net/netip"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"golang.org/x/net/idna"
|
||||
)
|
||||
|
||||
// Kinds of target.
|
||||
const (
|
||||
TargetDomain = "domain"
|
||||
TargetIP = "ip"
|
||||
TargetCIDR = "cidr"
|
||||
)
|
||||
|
||||
// Target is one network target a tool call declares, in normalised form. With
|
||||
// its scope, Host and Port are the key of a pin and of an approval request;
|
||||
// path, query and scheme are never part of it.
|
||||
type Target struct {
|
||||
Kind string // domain | ip | cidr
|
||||
Host string // lower-case punycode name, canonical IP literal, or masked CIDR
|
||||
Port int // 0 = port not declared
|
||||
}
|
||||
|
||||
// schemePorts are the default ports of the schemes a target is usually written
|
||||
// with. A URL of another scheme without an explicit port has port 0.
|
||||
var schemePorts = map[string]int{"http": 80, "https": 443, "ws": 80, "wss": 443, "ftp": 21}
|
||||
|
||||
// hostProfile maps a name the way a lookup does (case, width, punycode) without
|
||||
// the strict rules that would refuse an underscore label.
|
||||
var hostProfile = idna.New(idna.MapForLookup(), idna.BidiRule())
|
||||
|
||||
// NormalizeTarget parses one target written as a URL with a scheme, or as a
|
||||
// host, host:port, [IPv6]:port, IP literal or CIDR. The name is lower-cased, its
|
||||
// trailing dot, userinfo and IPv6 brackets are removed, the host of a URL is
|
||||
// percent-decoded, an IPv4-mapped IPv6 address is unmapped, the decimal, hex, octal and short forms of an IPv4
|
||||
// address become dotted, and a Unicode name becomes punycode. The port is the
|
||||
// explicit one, else the default of the scheme, else 0.
|
||||
func NormalizeTarget(raw string) (Target, bool) {
|
||||
raw = strings.TrimSpace(raw)
|
||||
def, isURL := 0, false
|
||||
if i := strings.Index(raw, "://"); i > 0 {
|
||||
isURL = true
|
||||
def = schemePorts[strings.ToLower(raw[:i])]
|
||||
raw = raw[i+3:]
|
||||
if end := strings.IndexAny(raw, "/?#"); end >= 0 {
|
||||
raw = raw[:end]
|
||||
}
|
||||
raw = raw[strings.LastIndexByte(raw, '@')+1:]
|
||||
}
|
||||
host, port := raw, ""
|
||||
switch {
|
||||
case strings.HasPrefix(raw, "["):
|
||||
end := strings.IndexByte(raw, ']')
|
||||
if end < 0 {
|
||||
return Target{}, false
|
||||
}
|
||||
host = raw[1:end]
|
||||
if rest := raw[end+1:]; rest != "" {
|
||||
if rest[0] != ':' {
|
||||
return Target{}, false
|
||||
}
|
||||
port = rest[1:]
|
||||
}
|
||||
case strings.Count(raw, ":") == 1:
|
||||
host, port, _ = strings.Cut(raw, ":")
|
||||
}
|
||||
if isURL && !strings.HasPrefix(raw, "[") {
|
||||
// A client decodes a percent-encoded host before it resolves it.
|
||||
if decoded, err := url.PathUnescape(host); err == nil {
|
||||
host = decoded
|
||||
}
|
||||
}
|
||||
t := Target{Port: def}
|
||||
if port != "" {
|
||||
n, err := strconv.Atoi(port)
|
||||
if err != nil || n < 1 || n > 65535 {
|
||||
return Target{}, false
|
||||
}
|
||||
t.Port = n
|
||||
}
|
||||
var ok bool
|
||||
t.Kind, t.Host, ok = normalizeHost(host, true)
|
||||
return t, ok
|
||||
}
|
||||
|
||||
// normalizeHost returns the kind and the canonical form of a host. numeric
|
||||
// accepts the forms of an IPv4 address that are not dotted decimal
|
||||
// (2130706433, 0x7f000001, 0177.0.0.1, 127.1), which a URL host may use; in
|
||||
// free text they would turn every number into an address.
|
||||
func normalizeHost(raw string, numeric bool) (kind, host string, ok bool) {
|
||||
h := strings.TrimSuffix(strings.ToLower(strings.TrimSpace(raw)), ".")
|
||||
if h == "" {
|
||||
return "", "", false
|
||||
}
|
||||
if strings.Contains(h, "/") {
|
||||
p, err := netip.ParsePrefix(h)
|
||||
if err != nil {
|
||||
return "", "", false
|
||||
}
|
||||
return TargetCIDR, p.Masked().String(), true
|
||||
}
|
||||
if a, err := netip.ParseAddr(h); err == nil {
|
||||
return TargetIP, a.Unmap().WithZone("").String(), true
|
||||
}
|
||||
if numeric {
|
||||
if a, ok := parseIPv4Forms(h); ok {
|
||||
return TargetIP, a.String(), true
|
||||
}
|
||||
}
|
||||
// A name the profile refuses is kept as written: it then fails to resolve and
|
||||
// the call is blocked, instead of passing as a call without a target.
|
||||
if name, err := hostProfile.ToASCII(h); err == nil && name != "" {
|
||||
h = name
|
||||
}
|
||||
return TargetDomain, h, true
|
||||
}
|
||||
|
||||
// parseIPv4Forms reads an IPv4 address the way inet_aton does: one to four
|
||||
// parts, each decimal, hex (0x) or octal (leading 0), the last one filling the
|
||||
// remaining bytes.
|
||||
func parseIPv4Forms(s string) (netip.Addr, bool) {
|
||||
parts := strings.Split(s, ".")
|
||||
if len(parts) > 4 {
|
||||
return netip.Addr{}, false
|
||||
}
|
||||
var v uint64
|
||||
for n, p := range parts {
|
||||
base := 10
|
||||
switch {
|
||||
case strings.HasPrefix(p, "0x"):
|
||||
p, base = p[2:], 16
|
||||
case len(p) > 1 && p[0] == '0':
|
||||
p, base = p[1:], 8
|
||||
}
|
||||
x, err := strconv.ParseUint(p, base, 32)
|
||||
if err != nil {
|
||||
return netip.Addr{}, false
|
||||
}
|
||||
if n < len(parts)-1 {
|
||||
if x > 255 {
|
||||
return netip.Addr{}, false
|
||||
}
|
||||
v = v<<8 | x
|
||||
continue
|
||||
}
|
||||
rest := uint(8 * (4 - n))
|
||||
if x >= 1<<rest {
|
||||
return netip.Addr{}, false
|
||||
}
|
||||
v = v<<rest | x
|
||||
}
|
||||
return netip.AddrFrom4([4]byte{byte(v >> 24), byte(v >> 16), byte(v >> 8), byte(v)}), true
|
||||
}
|
||||
|
||||
// validHostname reports whether h is a plain DNS name: labels of letters,
|
||||
// digits, hyphen and underscore. A wildcard or a pattern is not one.
|
||||
func validHostname(h string) bool {
|
||||
if len(h) > 253 {
|
||||
return false
|
||||
}
|
||||
for _, label := range strings.Split(h, ".") {
|
||||
if label == "" || len(label) > 63 || label[0] == '-' || label[len(label)-1] == '-' {
|
||||
return false
|
||||
}
|
||||
for _, c := range []byte(label) {
|
||||
if !(c >= 'a' && c <= 'z' || c >= '0' && c <= '9' || c == '-' || c == '_') {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
package intercept
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Autumn-27/artex/db"
|
||||
)
|
||||
|
||||
// The declared forms of a call decide first: the URL block rules on the URLs
|
||||
// the call writes, then the block rules on every name, IP literal and range as
|
||||
// written. Nothing is resolved before that, so a hit costs no lookup.
|
||||
|
||||
func TestExtractDeclaredURLs(t *testing.T) {
|
||||
raw := func(v any) []byte {
|
||||
b, _ := json.Marshal(v)
|
||||
return b
|
||||
}
|
||||
for _, c := range []struct {
|
||||
name string
|
||||
tool string
|
||||
input []byte
|
||||
want []string
|
||||
}{
|
||||
{"WebFetch", "WebFetch", raw(map[string]string{"url": " https://app.example.com/admin?x=1 "}), []string{"https://app.example.com/admin?x=1"}},
|
||||
{"WebFetch without a target", "WebFetch", raw(map[string]string{"url": "http:///path"}), nil},
|
||||
{"Bash, query kept", "Bash", raw(command("curl -s 'https://a.example.com/admin?x=1'")), []string{"https://a.example.com/admin?x=1"}},
|
||||
{"Bash, two URLs", "Bash", raw(command("curl http://a.example.com/x && curl https://b.example.com:8443/y")),
|
||||
[]string{"http://a.example.com/x", "https://b.example.com:8443/y"}},
|
||||
{"shell_send", "shell_send", raw(map[string]string{"text": "curl http://a.example.com/x\n"}), []string{"http://a.example.com/x"}},
|
||||
{"Monitor", "Monitor", raw(command("watch curl http://198.51.100.9:8080/status")), []string{"http://198.51.100.9:8080/status"}},
|
||||
{"MCP string leaves", "mcp__scan__run", []byte(`{"target":"http://a.example.com/p","opts":{"list":["https://b.example.com/q"]}}`),
|
||||
[]string{"http://a.example.com/p", "https://b.example.com/q"}},
|
||||
{"ExecuteExtraTool unwrap", executeExtraTool, raw(map[string]any{"tool_name": "WebFetch", "params": map[string]string{"url": "https://a.example.com/z"}}),
|
||||
[]string{"https://a.example.com/z"}},
|
||||
{"tool without a target", "web_search", raw(map[string]string{"query": "https://a.example.com/admin"}), nil},
|
||||
{"wrapped tool without a target", executeExtraTool, raw(map[string]any{"tool_name": "web_search", "params": map[string]string{"query": "https://a.example.com/admin"}}), nil},
|
||||
{"authority that names no host", "Bash", raw(command("curl http://$HOST/x https://${H}:8443/admin")), nil},
|
||||
} {
|
||||
d := extractDeclared(c.tool, c.input)
|
||||
if !slices.Equal(d.urls, c.want) {
|
||||
t.Errorf("%s: urls = %q, want %q", c.name, d.urls, c.want)
|
||||
}
|
||||
// The targets are those ExtractTargets returns.
|
||||
if !slices.Equal(d.targets, ExtractTargets(c.tool, c.input)) {
|
||||
t.Errorf("%s: targets differ from ExtractTargets", c.name)
|
||||
}
|
||||
if len(d.urls) > 0 && len(d.targets) == 0 {
|
||||
t.Errorf("%s: a URL without a target", c.name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A name that a name rule blocks is blocked on the name as written: no lookup
|
||||
// is made for it, nor for another name of the same call that no rule blocks.
|
||||
func TestBlockRuleOnDeclaredNameNeedsNoLookup(t *testing.T) {
|
||||
for _, rule := range []db.AssetInterceptRule{
|
||||
{Enabled: true, Kind: "exact_domain", Pattern: "blocked.example.com"},
|
||||
{Enabled: true, Kind: "fuzzy_domain", Pattern: "blocked.example"},
|
||||
} {
|
||||
g := newGate(t)
|
||||
g.res.set("ok.example.com", "203.0.113.7")
|
||||
g.res.set("blocked.example.com", "203.0.113.8")
|
||||
g.store.rules = []db.AssetInterceptRule{rule}
|
||||
for name, v := range map[string]verdict{
|
||||
"WebFetch": g.fetch("https://blocked.example.com/"),
|
||||
// The unblocked name comes first in the call.
|
||||
"two names": g.check("Bash", `{"command":"curl https://ok.example.com/ https://blocked.example.com/"}`),
|
||||
} {
|
||||
res := g.blocked(rule.Kind+", "+name, TargetRuleBlocked, v)
|
||||
if res.Host != "blocked.example.com" || !strings.Contains(res.Reason, rule.Pattern) {
|
||||
t.Fatalf("%s, %s: result = %+v", rule.Kind, name, res)
|
||||
}
|
||||
}
|
||||
if n := g.res.total(); n != 0 {
|
||||
t.Fatalf("%s: %d lookups, want none", rule.Kind, n)
|
||||
}
|
||||
for _, row := range g.store.logged {
|
||||
if a := row.Audit.Targets[0]; a.Outcome != "rule_blocked" || len(a.ResolvedIPs) != 0 {
|
||||
t.Fatalf("%s: logged target = %+v, want rule_blocked with no address", rule.Kind, a)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// URL rules apply at dispatch, on the URLs the call declares, before anything
|
||||
// is resolved. A hit blocks with the code of every block rule, opens no
|
||||
// request, and wins over a pin and an exemption.
|
||||
func TestURLBlockRuleAtDispatch(t *testing.T) {
|
||||
g := newGate(t)
|
||||
g.res.set("app.example.com", "203.0.113.7")
|
||||
g.res.set("pypi.org", "151.101.0.223")
|
||||
g.store.exempt = []db.TargetExemption{{Host: "pypi.org", Port: 443}}
|
||||
g.store.approve(TargetRequest{Host: "app.example.com", Port: 443, ResolvedIPs: []string{"203.0.113.7"}})
|
||||
g.passes("pinned", g.fetch("https://app.example.com/admin"))
|
||||
g.passes("exempt", g.fetch("https://pypi.org/admin"))
|
||||
|
||||
rule := func(kind, pattern string, enabled bool) []db.AssetInterceptRule {
|
||||
return []db.AssetInterceptRule{{Enabled: enabled, Kind: kind, Pattern: pattern}}
|
||||
}
|
||||
// A disabled URL rule blocks nothing.
|
||||
g.store.rules = rule("exact_url", "https://app.example.com/admin", false)
|
||||
g.passes("disabled rule", g.fetch("https://app.example.com/admin"))
|
||||
|
||||
lookups, rows := g.res.total(), len(g.store.logged)
|
||||
for _, c := range []struct {
|
||||
name string
|
||||
rules []db.AssetInterceptRule
|
||||
v func() verdict
|
||||
host string
|
||||
port int
|
||||
matchedURL string
|
||||
}{
|
||||
{"exact_url, WebFetch of a pinned host", rule("exact_url", "https://app.example.com/admin", true),
|
||||
func() verdict { return g.fetch("https://app.example.com/admin") }, "app.example.com", 443, "https://app.example.com/admin"},
|
||||
{"fuzzy_url, exempt host", rule("fuzzy_url", "/admin", true),
|
||||
func() verdict { return g.fetch("https://pypi.org/admin") }, "pypi.org", 443, "https://pypi.org/admin"},
|
||||
{"fuzzy_url, Bash with a query", rule("fuzzy_url", "/admin", true),
|
||||
func() verdict { return g.check("Bash", `{"command":"curl -s 'https://app.example.com/admin?x=1'"}`) }, "app.example.com", 443, "https://app.example.com/admin?x=1"},
|
||||
{"fuzzy_url, second URL of the call", rule("fuzzy_url", "/admin", true),
|
||||
func() verdict {
|
||||
return g.check("Bash", `{"command":"curl https://app.example.com/ && curl http://198.51.100.9:8080/admin"}`)
|
||||
}, "198.51.100.9", 8080, "http://198.51.100.9:8080/admin"},
|
||||
{"fuzzy_url, MCP input", rule("fuzzy_url", "/ADMIN", true),
|
||||
func() verdict { return g.check("mcp__scan__run", `{"target":"https://app.example.com/admin/users"}`) }, "app.example.com", 443, "https://app.example.com/admin/users"},
|
||||
{"exact_url, wrapped WebFetch", rule("exact_url", "https://app.example.com/admin", true),
|
||||
func() verdict {
|
||||
return g.check(executeExtraTool, `{"tool_name":"WebFetch","params":{"url":"https://app.example.com/admin"}}`)
|
||||
}, "app.example.com", 443, "https://app.example.com/admin"},
|
||||
} {
|
||||
g.store.rules = c.rules
|
||||
g.res.set("app.example.com", "198.51.100.200") // a lookup would make a pinned host ask
|
||||
res := g.blocked(c.name, TargetRuleBlocked, c.v())
|
||||
if res.Host != c.host || res.Port != c.port || !strings.Contains(res.Reason, c.matchedURL+" is blocked by an asset rule") ||
|
||||
!strings.Contains(res.Reason, c.rules[0].Pattern) {
|
||||
t.Errorf("%s: result = %+v", c.name, res)
|
||||
}
|
||||
}
|
||||
if n := g.res.total() - lookups; n != 0 {
|
||||
t.Fatalf("%d lookups for calls a URL rule blocks, want none", n)
|
||||
}
|
||||
if got := g.store.outcomes()[rows:]; len(got) != 6 || slices.ContainsFunc(got, func(o string) bool { return o != "denied/rule_blocked" }) {
|
||||
t.Fatalf("logged rows = %v, want 6 rule_blocked rows", got)
|
||||
}
|
||||
|
||||
// The rendered URL of a custom http tool and the URLs of a rendered command
|
||||
// meet the same rules, with no lookup.
|
||||
g.store.rules = rule("exact_url", "https://app.example.com/admin", true)
|
||||
if res := GateRenderedURL(g.ctx(), "probe_http", "https://app.example.com/admin"); res.Code != TargetRuleBlocked || res.Host != "app.example.com" {
|
||||
t.Fatalf("rendered URL: %+v", res)
|
||||
}
|
||||
g.store.rules = rule("fuzzy_url", "/admin", true)
|
||||
if res := GateRenderedCommand(g.ctx(), "probe_cmd", "curl 'https://app.example.com/admin'"); res.Code != TargetRuleBlocked {
|
||||
t.Fatalf("rendered command: %+v", res)
|
||||
}
|
||||
if n := g.res.total() - lookups; n != 0 {
|
||||
t.Fatalf("%d lookups for rendered calls a URL rule blocks, want none", n)
|
||||
}
|
||||
}
|
||||
|
||||
// Documents the known limit of URL rules: they match the URL as the call
|
||||
// writes it. Another spelling of the same URL is not blocked. For a host, a
|
||||
// domain, IP or CIDR rule is the one to write.
|
||||
func TestExactURLRuleMatchesOnlyAsWritten(t *testing.T) {
|
||||
g := newGate(t)
|
||||
g.res.set("app.example.com", "203.0.113.7")
|
||||
g.store.approve(TargetRequest{Host: "app.example.com", Port: 443, ResolvedIPs: []string{"203.0.113.7"}})
|
||||
|
||||
g.store.rules = []db.AssetInterceptRule{{Enabled: true, Kind: "exact_url", Pattern: "https://app.example.com/admin"}}
|
||||
g.blocked("as written", TargetRuleBlocked, g.fetch("https://app.example.com/admin"))
|
||||
for _, u := range []string{
|
||||
"https://APP.example.com/admin", // case of the host
|
||||
"https://app.example.com/Admin", // case of the path
|
||||
"https://app.example.com/admin/", // trailing slash
|
||||
"https://app.example.com/admin?x=1", // added query
|
||||
"https://app.example.com/%61dmin", // percent-encoding
|
||||
"https://app.example.com/./admin", // dot segment
|
||||
"https://app.example.com:443/admin", // default port written out
|
||||
} {
|
||||
g.passes("exact_url, "+u, g.fetch(u))
|
||||
}
|
||||
|
||||
g.store.rules = []db.AssetInterceptRule{{Enabled: true, Kind: "fuzzy_url", Pattern: "/admin"}}
|
||||
g.blocked("fuzzy, as written", TargetRuleBlocked, g.fetch("https://app.example.com/x/admin"))
|
||||
for _, u := range []string{"https://app.example.com/%61dmin", "https://app.example.com/adm%69n", "https://app.example.com//%2fadmin"} {
|
||||
if strings.Contains(strings.ToLower(u), "/admin") {
|
||||
t.Fatalf("%s contains the pattern; the case documents an encoded form", u)
|
||||
}
|
||||
g.passes("fuzzy_url, "+u, g.fetch(u))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package intercept
|
||||
|
||||
import "testing"
|
||||
|
||||
// An operator may exempt a private unicast IP literal, never a loopback,
|
||||
// link-local (cloud metadata), unspecified or multicast one.
|
||||
func TestValidateExemptionHost(t *testing.T) {
|
||||
for _, host := range []string{
|
||||
"10.0.0.5", "172.16.0.1", "192.168.1.1", "fd00::1", // private unicast
|
||||
"203.0.113.7", "2001:db8::1", // public
|
||||
"pypi.org", "internal.corp", "localhost", // names: the void rule's business
|
||||
} {
|
||||
if err := ValidateExemptionHost(host); err != nil {
|
||||
t.Errorf("%s refused: %v", host, err)
|
||||
}
|
||||
}
|
||||
for _, host := range []string{
|
||||
"169.254.169.254", "169.254.170.2", "169.254.0.1", "fe80::1", "fe80::1%eth0", // link-local, metadata
|
||||
"::ffff:169.254.169.254", // the same address, IPv4-mapped
|
||||
"fd00:ec2::254", "100.100.100.200", // metadata outside link-local
|
||||
"127.0.0.1", "127.8.8.8", "::1", "::ffff:127.0.0.1", // loopback
|
||||
"0.0.0.0", "::", // unspecified
|
||||
"224.0.0.1", "239.255.255.250", "ff02::1", "ff05::2", // multicast
|
||||
} {
|
||||
if err := ValidateExemptionHost(host); err == nil {
|
||||
t.Errorf("%s accepted, want it refused", host)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,334 @@
|
||||
package intercept
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/netip"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// executeExtraTool is the tool that runs a deferred tool (every MCP tool, and
|
||||
// the custom tools flagged deferred). The hook sees it, never the inner name.
|
||||
const executeExtraTool = "ExecuteExtraTool"
|
||||
|
||||
// noTargetTools are the tools whose input names no network target: the search
|
||||
// tool (its input is a query), the file and session tools, and the platform's
|
||||
// domain tools, which read and write the platform's own records. Every name
|
||||
// that is not listed here is scanned, so a tool added later is gated by default.
|
||||
var noTargetTools = toolSet(
|
||||
"web_search",
|
||||
"Read", "Write", "Edit", "MultiEdit", "LS", "Glob", "Grep",
|
||||
"TodoWrite", "sleep", "TaskOutput", "TaskStop", "TaskList",
|
||||
// exploration graph, assets, findings, goals (agent.ToolSet)
|
||||
"graph_overview", "list_findings", "list_facts", "node_detail", "expand_digest",
|
||||
"get_worker_output", "get_worker_trace", "search_all_worker_traces",
|
||||
"add_hint", "add_intent", "steer_work", "kill_work",
|
||||
"set_goals", "set_constraints", "list_goals", "prove_goal", "goal_met",
|
||||
"insert_assets", "add_company_scope", "add_task_scope", "list_assets",
|
||||
"list_untested_assets", "list_companies", "report_finding", "record_fact",
|
||||
"save_task_credential", "list_task_credentials",
|
||||
// cross-task orchestration, findings and retests (server)
|
||||
"list_tasks", "list_llm_profiles", "spawn_task", "pause_task", "get_task_graph",
|
||||
"list_task_findings", "add_task_hint", "get_task_worker_trace",
|
||||
"list_task_worker_traces", "search_task_worker_traces", "get_task_node_detail",
|
||||
"update_finding_report", "get_finding_traffic", "bind_finding_traffic",
|
||||
"get_finding_retest_context", "record_finding_retest_result",
|
||||
"delete_assets_by_host", "create_skill", "update_skill_file",
|
||||
// the operator runs the command, not the platform
|
||||
"ask_operator",
|
||||
// recorded traffic
|
||||
"traffic_search", "traffic_get", "traffic_blob",
|
||||
)
|
||||
|
||||
func toolSet(names ...string) map[string]bool {
|
||||
m := make(map[string]bool, len(names))
|
||||
for _, n := range names {
|
||||
m[n] = true
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// ExtractTargets returns the network targets a tool call declares in its
|
||||
// input, normalised and without duplicates, in order of appearance.
|
||||
//
|
||||
// WebFetch names its target exactly. The command tools are read with the
|
||||
// command-string rules: URLs with a scheme, IP literals, IP:port, host:port and
|
||||
// CIDR. A bare host name is not a target there, because a file name such as
|
||||
// x.py is a valid host name. Every other tool that is not known to have no
|
||||
// target has each string of its input read with the same rules. This is what
|
||||
// the model declared: a command can hide its target, and the process resolves
|
||||
// names on its own. The gate detects a declared target; it does not prevent an
|
||||
// undeclared connection.
|
||||
func ExtractTargets(toolName string, input []byte) []Target {
|
||||
return extractDeclared(toolName, input).targets
|
||||
}
|
||||
|
||||
// extractDeclared reads what a tool call declares: its targets, as
|
||||
// ExtractTargets returns them, and the URLs it writes, as written, for the URL
|
||||
// block rules. A URL is kept only when it names a target.
|
||||
func extractDeclared(toolName string, input []byte) targetList {
|
||||
if toolName == executeExtraTool {
|
||||
var in struct {
|
||||
ToolName string `json:"tool_name"`
|
||||
Params json.RawMessage `json:"params"`
|
||||
}
|
||||
if json.Unmarshal(input, &in) == nil && in.ToolName != "" && in.ToolName != executeExtraTool {
|
||||
toolName, input = in.ToolName, in.Params
|
||||
}
|
||||
}
|
||||
if noTargetTools[toolName] {
|
||||
return targetList{}
|
||||
}
|
||||
var out targetList
|
||||
switch toolName {
|
||||
case "WebFetch":
|
||||
u := declaredFields(input).URL
|
||||
if t, ok := NormalizeTarget(u); ok {
|
||||
out.add(t)
|
||||
out.urls = append(out.urls, strings.TrimSpace(u))
|
||||
}
|
||||
case "Bash", "shell_open", "Monitor":
|
||||
out.scan(declaredFields(input).Command)
|
||||
case "shell_send":
|
||||
out.scan(declaredFields(input).Text)
|
||||
default:
|
||||
out.scanStrings(input)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// declaredInput holds the fields of the built-in tools that carry a target.
|
||||
type declaredInput struct {
|
||||
URL string `json:"url"`
|
||||
Command string `json:"command"`
|
||||
Text string `json:"text"`
|
||||
}
|
||||
|
||||
// declaredFields decodes the input into a struct, as the tools themselves do:
|
||||
// a key matches whatever its case and the last duplicate wins. The gate
|
||||
// therefore reads the value the tool will run, not another spelling of the key.
|
||||
func declaredFields(input []byte) declaredInput {
|
||||
var in declaredInput
|
||||
_ = json.Unmarshal(input, &in) // a field of another type is skipped, the others are kept
|
||||
return in
|
||||
}
|
||||
|
||||
type targetList struct {
|
||||
targets []Target
|
||||
seen map[Target]bool
|
||||
urls []string // the URLs that name a target, as written
|
||||
}
|
||||
|
||||
func (l *targetList) add(t Target) {
|
||||
if l.seen[t] {
|
||||
return
|
||||
}
|
||||
if l.seen == nil {
|
||||
l.seen = map[Target]bool{}
|
||||
}
|
||||
l.seen[t] = true
|
||||
l.targets = append(l.targets, t)
|
||||
}
|
||||
|
||||
// scanStrings reads every string of a JSON input, in document order. It walks
|
||||
// the tokens instead of decoding into a map, so both values of a duplicated key
|
||||
// are read: the gate cannot know which one the tool behind the call keeps.
|
||||
func (l *targetList) scanStrings(input []byte) {
|
||||
dec := json.NewDecoder(bytes.NewReader(input))
|
||||
for {
|
||||
tok, err := dec.Token()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if s, ok := tok.(string); ok {
|
||||
l.scan(s)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// shellMeta are the characters that end a word for an unquoted shell.
|
||||
const shellMeta = `|;&(){},\`
|
||||
|
||||
// urlHosts returns the readings of the authority of a URL found in a command.
|
||||
// Whether the URL is quoted decides how its authority is read when a shell
|
||||
// character sits in it: 'http://a.example;@b.example/' names b.example to the
|
||||
// client, and without the quotes the shell ends the URL at a.example. A
|
||||
// backslash is dropped by the shell and kept inside quotes. The gate cannot
|
||||
// tell which applies, so it returns every reading and each one is a target.
|
||||
func urlHosts(authority string) []string {
|
||||
host := func(s string) string {
|
||||
s = s[strings.LastIndexByte(s, '@')+1:]
|
||||
if i := strings.IndexAny(s, shellMeta); i >= 0 {
|
||||
s = s[:i]
|
||||
}
|
||||
return s
|
||||
}
|
||||
out := []string{host(authority)}
|
||||
if i := strings.IndexAny(authority, shellMeta); i >= 0 {
|
||||
out = append(out, host(authority[:i]), host(strings.ReplaceAll(authority, `\`, "")))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
var (
|
||||
// A URL with a scheme: the scheme, the authority, then the rest of the token.
|
||||
// The rest is consumed so that what a path or a query contains is not read
|
||||
// as a target.
|
||||
reURL = regexp.MustCompile(`(?i)\b([a-z][a-z0-9+.\-]*://)([^\s/?#"'<>` + "`" + `]*)[^\s"'<>` + "`" + `]*`)
|
||||
// [IPv6] and [IPv6]:port.
|
||||
reBracketed = regexp.MustCompile(`\[([0-9A-Fa-f:.]+(?:%[\w.\-]+)?)\](?::(\d{1,5}))?`)
|
||||
// IPv4, IPv4:port and IPv4/len.
|
||||
reIPv4 = regexp.MustCompile(`\d{1,3}(?:\.\d{1,3}){3}(?:/\d{1,2}|:\d{1,5})?`)
|
||||
// IPv6 and IPv6/len: at least two colons, checked by the parser afterwards.
|
||||
reIPv6 = regexp.MustCompile(`[0-9A-Fa-f:.]*:[0-9A-Fa-f:.]*:[0-9A-Fa-f:.]*(?:%[\w.\-]+)?(?:/\d{1,3})?`)
|
||||
// name:port, for a name with a dot and an alphabetic last label, or localhost.
|
||||
// A single label is not read as a host: user:1234 and image:22 are not targets.
|
||||
reHostPort = regexp.MustCompile(`(?i)(?:localhost|[a-z0-9_](?:[a-z0-9_\-]*[a-z0-9_])?(?:\.[a-z0-9_](?:[a-z0-9_\-]*[a-z0-9_])?)*\.[a-z][a-z0-9\-]*[a-z0-9]):\d{1,5}`)
|
||||
)
|
||||
|
||||
// scan reads the targets of one string with the command-string rules. Each
|
||||
// form is blanked once it is read, so a later rule never reads part of it again.
|
||||
func (l *targetList) scan(s string) {
|
||||
if s == "" {
|
||||
return
|
||||
}
|
||||
b := []byte(s)
|
||||
blank := func(from, to int) {
|
||||
for i := from; i < to; i++ {
|
||||
b[i] = ' '
|
||||
}
|
||||
}
|
||||
each := func(re *regexp.Regexp, read func(m []int) bool) {
|
||||
for _, m := range re.FindAllSubmatchIndex(b, -1) {
|
||||
if read(m) {
|
||||
blank(m[0], m[1])
|
||||
}
|
||||
}
|
||||
}
|
||||
each(reURL, func(m []int) bool {
|
||||
scheme, found := string(b[m[2]:m[3]]), false
|
||||
for _, host := range urlHosts(string(b[m[4]:m[5]])) {
|
||||
// A host that is not a name ($HOST, a glob) is what the shell or the
|
||||
// client will make of it, which the gate cannot read.
|
||||
if t, ok := NormalizeTarget(scheme + host); ok && (t.Kind != TargetDomain || validHostname(t.Host)) {
|
||||
l.add(t)
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if found {
|
||||
// The whole token, path and query included, from the original string.
|
||||
l.urls = append(l.urls, s[m[0]:m[1]])
|
||||
}
|
||||
// The scheme and what follows the authority are never read again. An
|
||||
// authority that named no host is: an address written in it still counts.
|
||||
blank(m[0], m[4])
|
||||
blank(m[5], m[1])
|
||||
return found
|
||||
})
|
||||
each(reBracketed, func(m []int) bool {
|
||||
a, err := netip.ParseAddr(string(b[m[2]:m[3]]))
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
t := Target{Kind: TargetIP, Host: a.Unmap().WithZone("").String()}
|
||||
if m[4] >= 0 {
|
||||
t.Port = validPort(string(b[m[4]:m[5]]))
|
||||
}
|
||||
l.add(t)
|
||||
return true
|
||||
})
|
||||
each(reIPv6, func(m []int) bool {
|
||||
start, end := m[0], m[1]
|
||||
if start > 0 && isWord(b[start-1]) || end < len(b) && (isWord(b[end]) || b[end] == ':') {
|
||||
return false
|
||||
}
|
||||
kind, host, ok := normalizeHost(string(b[start:end]), false)
|
||||
// "::" alone is punctuation (a slice, a scope operator), not an address.
|
||||
if !ok || kind == TargetDomain || host == "::" {
|
||||
return false
|
||||
}
|
||||
l.add(Target{Kind: kind, Host: host})
|
||||
return true
|
||||
})
|
||||
each(reIPv4, func(m []int) bool {
|
||||
start, end := m[0], m[1]
|
||||
if start > 0 && (isWord(b[start-1]) || b[start-1] == '.') {
|
||||
return false
|
||||
}
|
||||
// The address alone, when what follows it is not a port or a prefix length.
|
||||
addrEnd := start + bytes.IndexAny(b[start:end], "/:")
|
||||
if addrEnd < start {
|
||||
addrEnd = end
|
||||
}
|
||||
if !endsToken(b, end) {
|
||||
end = addrEnd
|
||||
if !endsToken(b, end) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
a, err := netip.ParseAddr(string(b[start:addrEnd]))
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
t := Target{Kind: TargetIP, Host: a.String()}
|
||||
if end > addrEnd {
|
||||
switch suffix := string(b[addrEnd+1 : end]); b[addrEnd] {
|
||||
case '/':
|
||||
if p, err := netip.ParsePrefix(string(b[start:end])); err == nil {
|
||||
t = Target{Kind: TargetCIDR, Host: p.Masked().String()}
|
||||
}
|
||||
case ':':
|
||||
t.Port = validPort(suffix)
|
||||
}
|
||||
}
|
||||
l.add(t)
|
||||
return true
|
||||
})
|
||||
each(reHostPort, func(m []int) bool {
|
||||
start, end := m[0], m[1]
|
||||
// Not the tail of a longer word, a path or an address, and not the head of
|
||||
// a version number (name:1.25).
|
||||
if start > 0 && (isWord(b[start-1]) || strings.IndexByte(`.-/\`, b[start-1]) >= 0) {
|
||||
return false
|
||||
}
|
||||
if !endsToken(b, end) {
|
||||
return false
|
||||
}
|
||||
host, port, _ := strings.Cut(string(b[start:end]), ":")
|
||||
n := validPort(port)
|
||||
_, host, ok := normalizeHost(host, false)
|
||||
if n == 0 || !ok {
|
||||
return false
|
||||
}
|
||||
l.add(Target{Kind: TargetDomain, Host: host, Port: n})
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
// endsToken reports whether the token that ends before b[i] really ends there:
|
||||
// what follows is not a word character and not a dot that continues a name or
|
||||
// a number.
|
||||
func endsToken(b []byte, i int) bool {
|
||||
if i >= len(b) {
|
||||
return true
|
||||
}
|
||||
if isWord(b[i]) {
|
||||
return false
|
||||
}
|
||||
return b[i] != '.' || i+1 >= len(b) || !isWord(b[i+1])
|
||||
}
|
||||
|
||||
func isWord(c byte) bool {
|
||||
return c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || c >= '0' && c <= '9' || c == '_'
|
||||
}
|
||||
|
||||
// validPort returns the port s names, 0 when it is not one.
|
||||
func validPort(s string) int {
|
||||
n, err := strconv.Atoi(s)
|
||||
if err != nil || n < 1 || n > 65535 {
|
||||
return 0
|
||||
}
|
||||
return n
|
||||
}
|
||||
@@ -0,0 +1,389 @@
|
||||
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
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
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))
|
||||
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, false)
|
||||
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] {
|
||||
uses = append(uses, func() { c.exemptUsed(ct, ex.digest) })
|
||||
continue
|
||||
}
|
||||
if ex.stored[key] {
|
||||
if exemptionHolds(ct) {
|
||||
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 {
|
||||
// A request is about to be raised: the addresses a human reads and
|
||||
// pins are resolved now, never taken from the cache.
|
||||
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 {
|
||||
uses = append(uses, func() { c.pinUsed(ct, pin) })
|
||||
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"
|
||||
}
|
||||
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.
|
||||
func (c *targetCheck) pinUsed(ct *checkedTarget, pin *db.TargetPin) {
|
||||
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
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,475 @@
|
||||
package intercept
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Autumn-27/artex/db"
|
||||
)
|
||||
|
||||
// The tests below run the gate against PostgreSQL: the real store, the real
|
||||
// request and decide path, and a fake resolver.
|
||||
|
||||
// pgGate returns an interceptor on d with a fake resolver.
|
||||
func pgGate(t *testing.T, d *db.DB) (*Interceptor, *fakeResolver) {
|
||||
t.Helper()
|
||||
ic, res := New(d), newFakeResolver()
|
||||
ic.SetResolver(res)
|
||||
return ic, res
|
||||
}
|
||||
|
||||
// storedExemptions sets the exemption list for one test and puts the previous
|
||||
// one back afterwards. The list is global, so these tests do not run in parallel.
|
||||
func storedExemptions(t *testing.T, d *db.DB, list ...db.TargetExemption) {
|
||||
t.Helper()
|
||||
previous, _, err := d.TargetExemptions()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := d.ReplaceTargetExemptions(list, "test", "test"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = d.ReplaceTargetExemptions(previous, "test", "test") })
|
||||
}
|
||||
|
||||
// decidedRows returns the rows of the gate that no human decided, oldest first.
|
||||
func decidedRows(t *testing.T, d *db.DB, scope TargetScope) []*db.InterceptDetail {
|
||||
t.Helper()
|
||||
rows, err := d.Query(`SELECT id FROM intercept_pending
|
||||
WHERE COALESCE(task_id,'')=$1 AND COALESCE(conversation_id,0)=$2 AND decision_source='target' AND decided_by='system' AND target_key IS NULL ORDER BY id`,
|
||||
scope.TaskID, scope.ConversationID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var ids []int64
|
||||
for rows.Next() {
|
||||
var id int64
|
||||
if err := rows.Scan(&id); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ids = append(ids, id)
|
||||
}
|
||||
rows.Close()
|
||||
out := make([]*db.InterceptDetail, len(ids))
|
||||
for n, id := range ids {
|
||||
if out[n], err = d.GetInterceptDetail(id); err != nil || out[n] == nil || out[n].Audit == nil || len(out[n].Audit.Targets) == 0 {
|
||||
t.Fatalf("row %d: detail=%+v err=%v", id, out[n], err)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func fetchInput(url string) []byte { return []byte(`{"url":"` + url + `"}`) }
|
||||
|
||||
// approveAll approves every open request of scope.
|
||||
func approveAll(t *testing.T, d *db.DB, ic *Interceptor, scope TargetScope) {
|
||||
t.Helper()
|
||||
ids := openRows(t, d, scope)
|
||||
if len(ids) == 0 {
|
||||
t.Fatal("no open request to approve")
|
||||
}
|
||||
for _, id := range ids {
|
||||
if err := ic.Decide(id, true, "ARTEX", "192.0.2.1"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// pin opens the request for an unpinned WebFetch target and approves it.
|
||||
func pinURL(t *testing.T, d *db.DB, ic *Interceptor, scope TargetScope, url string) {
|
||||
t.Helper()
|
||||
reqs, res := ic.CheckTargets(context.Background(), scope, "WebFetch", fetchInput(url))
|
||||
if res.Code != "" || len(reqs) != 1 {
|
||||
t.Fatalf("%s: requests=%+v result=%+v, want one request", url, reqs, res)
|
||||
}
|
||||
done := request(context.Background(), ic, scope, nil, reqs...)
|
||||
eventually(t, "the request row", func() bool { return len(openRows(t, d, scope)) == 1 })
|
||||
approveAll(t, d, ic, scope)
|
||||
if out := result(t, done); out.Code != "" {
|
||||
t.Fatalf("%s: approved request ended with %+v", url, out)
|
||||
}
|
||||
}
|
||||
|
||||
func mustPass(t *testing.T, what string, ic *Interceptor, scope TargetScope, url string) {
|
||||
t.Helper()
|
||||
if reqs, res := ic.CheckTargets(context.Background(), scope, "WebFetch", fetchInput(url)); res.Code != "" || len(reqs) != 0 {
|
||||
t.Fatalf("%s: requests=%+v result=%+v, want the call to pass", what, reqs, res)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExemptionChangeIsLogged(t *testing.T) {
|
||||
d := targetTestDB(t)
|
||||
ic := New(d)
|
||||
original, _, err := ic.Exemptions()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = d.ReplaceTargetExemptions(original, "test", "test") })
|
||||
logged := func() []db.TargetExemptionChange {
|
||||
t.Helper()
|
||||
changes, err := d.ListTargetExemptionLog(100)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return changes
|
||||
}
|
||||
before := logged()
|
||||
|
||||
first := []db.TargetExemption{{Host: "PyPI.org.", Port: 443, Note: " index "}, {Host: "10.0.0.5", Port: 8080}, {Host: "pypi.org", Port: 443, Note: "duplicate"}}
|
||||
stored, digest, err := ic.SetExemptions(first, "alice")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := []db.TargetExemption{{Host: "pypi.org", Port: 443, Note: "index"}, {Host: "10.0.0.5", Port: 8080}}
|
||||
if !slices.Equal(stored, want) {
|
||||
t.Fatalf("stored = %+v, want %+v (normalised, without the duplicate)", stored, want)
|
||||
}
|
||||
if list, got, err := ic.Exemptions(); err != nil || !slices.Equal(list, want) || got != digest || digest == "" {
|
||||
t.Fatalf("read back: list=%+v digest=%q (want %q) err=%v", list, got, digest, err)
|
||||
}
|
||||
second, digest2, err := ic.SetExemptions(nil, "bob")
|
||||
if err != nil || len(second) != 0 || digest2 == digest {
|
||||
t.Fatalf("empty list: stored=%+v digest=%q err=%v", second, digest2, err)
|
||||
}
|
||||
|
||||
// A refused list changes nothing and logs nothing.
|
||||
if _, _, err := ic.SetExemptions([]db.TargetExemption{{Host: "169.254.169.254", Port: 80}}, "mallory"); !errors.Is(err, ErrInvalidExemption) {
|
||||
t.Fatalf("metadata address: err=%v, want it refused", err)
|
||||
}
|
||||
if list, got, err := ic.Exemptions(); err != nil || len(list) != 0 || got != digest2 {
|
||||
t.Fatalf("after the refused change: list=%+v digest=%q err=%v", list, got, err)
|
||||
}
|
||||
|
||||
changes := logged()
|
||||
if len(changes) != len(before)+2 {
|
||||
t.Fatalf("%d log rows added, want 2", len(changes)-len(before))
|
||||
}
|
||||
newest, older := changes[0], changes[1]
|
||||
var olderBefore, olderAfter, newestBefore, newestAfter []db.TargetExemption
|
||||
for raw, into := range map[*json.RawMessage]*[]db.TargetExemption{&older.Before: &olderBefore, &older.After: &olderAfter, &newest.Before: &newestBefore, &newest.After: &newestAfter} {
|
||||
if len(*raw) > 0 {
|
||||
if err := json.Unmarshal(*raw, into); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
if older.ChangedBy != "alice" || older.Digest != digest || !slices.Equal(olderBefore, original) || !slices.Equal(olderAfter, want) || older.ChangedAt.IsZero() {
|
||||
t.Fatalf("first change = %+v", older)
|
||||
}
|
||||
if newest.ChangedBy != "bob" || newest.Digest != digest2 || !slices.Equal(newestBefore, want) || len(newestAfter) != 0 || string(newest.After) != "[]" {
|
||||
t.Fatalf("second change = %+v", newest)
|
||||
}
|
||||
}
|
||||
|
||||
// The first call that passes through an exemption is logged once per scope,
|
||||
// host, port and list digest, and per process.
|
||||
func TestExemptionFirstUseLoggedOnce(t *testing.T) {
|
||||
d := targetTestDB(t)
|
||||
storedExemptions(t, d, db.TargetExemption{Host: "pypi.org", Port: 443})
|
||||
ic, res := pgGate(t, d)
|
||||
ic.SetDerivedExemptions(func() []string { return []string{"https://llm.example.net/v1"} })
|
||||
res.set("pypi.org", "151.101.0.223")
|
||||
scope := taskScope(t, d, ic, time.Minute)
|
||||
_, digest, err := ic.Exemptions()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
for n := 0; n < 3; n++ {
|
||||
mustPass(t, "stored exemption", ic, scope, "https://pypi.org/simple/")
|
||||
mustPass(t, "derived exemption", ic, scope, "https://llm.example.net/v1/models")
|
||||
}
|
||||
rows := decidedRows(t, d, scope)
|
||||
if len(rows) != 2 {
|
||||
t.Fatalf("%d first-use rows, want one per exemption", len(rows))
|
||||
}
|
||||
for n, host := range []string{"pypi.org", "llm.example.net"} {
|
||||
row := rows[n]
|
||||
target := row.Audit.Targets[0]
|
||||
if row.Status != "allowed" || row.DecidedBy != "system" || row.DecisionSource != "target" || row.ToolName != "WebFetch" ||
|
||||
target.Host != host || target.Port != 443 || target.Outcome != "exempt" || row.Audit.ConfigDigest != digest || row.DecidedAt == nil {
|
||||
t.Fatalf("first-use row %d = %+v audit=%+v", n, row.InterceptApprovalRow, row.Audit)
|
||||
}
|
||||
}
|
||||
if n := len(pins(t, d, scope)) + len(openRows(t, d, scope)); n != 0 {
|
||||
t.Fatalf("an exemption created %d pins or requests", n)
|
||||
}
|
||||
|
||||
// Another scope logs its own first use.
|
||||
other := taskScope(t, d, ic, time.Minute)
|
||||
mustPass(t, "other task", ic, other, "https://pypi.org/simple/")
|
||||
if n := len(decidedRows(t, d, other)); n != 1 {
|
||||
t.Fatalf("other task: %d first-use rows, want 1", n)
|
||||
}
|
||||
// A changed list has another digest: the use is logged again.
|
||||
storedExemptions(t, d, db.TargetExemption{Host: "pypi.org", Port: 443}, db.TargetExemption{Host: "files.pythonhosted.org", Port: 443})
|
||||
mustPass(t, "changed list", ic, scope, "https://pypi.org/simple/")
|
||||
mustPass(t, "changed list, again", ic, scope, "https://pypi.org/simple/")
|
||||
if n := len(decidedRows(t, d, scope)); n != 3 {
|
||||
t.Fatalf("after the list change: %d rows, want 3", n)
|
||||
}
|
||||
// Another process (a second interceptor on the same database) logs again.
|
||||
second, res2 := pgGate(t, d)
|
||||
res2.set("pypi.org", "151.101.0.223")
|
||||
scope.Interceptor = second
|
||||
mustPass(t, "second process", second, scope, "https://pypi.org/simple/")
|
||||
if n := len(decidedRows(t, d, scope)); n != 4 {
|
||||
t.Fatalf("second process: %d rows, want 4", n)
|
||||
}
|
||||
}
|
||||
|
||||
// The seeded package sources are stored entries like the ones an operator
|
||||
// adds: the void rule applies to them.
|
||||
func TestSeededExemptionIsVoidOnPrivateAddress(t *testing.T) {
|
||||
d := targetTestDB(t)
|
||||
stored, _, err := d.TargetExemptions()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !slices.ContainsFunc(stored, func(e db.TargetExemption) bool { return e.Host == "pypi.org" && e.Port == 443 }) {
|
||||
t.Skipf("the stored list no longer holds the seeded pypi.org:443 entry: %+v", stored)
|
||||
}
|
||||
ic, res := pgGate(t, d)
|
||||
scope := taskScope(t, d, ic, time.Minute)
|
||||
res.set("pypi.org", "151.101.0.223")
|
||||
mustPass(t, "seeded entry, public address", ic, scope, "https://pypi.org/simple/")
|
||||
|
||||
second, res2 := pgGate(t, d)
|
||||
res2.set("pypi.org", "151.101.0.223", "10.0.0.5")
|
||||
reqs, out := second.CheckTargets(context.Background(), scope, "WebFetch", fetchInput("https://pypi.org/simple/"))
|
||||
if out.Code != "" || len(reqs) != 1 || !reqs[0].ExemptVoid {
|
||||
t.Fatalf("seeded entry, one private address: requests=%+v result=%+v, want a request with a void exemption", reqs, out)
|
||||
}
|
||||
// The request row records that the exemption was void.
|
||||
done := request(context.Background(), second, scope, nil, reqs...)
|
||||
id := oneOpenRow(t, d, scope)
|
||||
detail, err := d.GetInterceptDetail(id)
|
||||
if err != nil || detail == nil || !detail.Audit.Targets[0].ExemptVoid {
|
||||
t.Fatalf("request row audit = %+v err=%v, want exempt_void", detail, err)
|
||||
}
|
||||
if err := second.Decide(id, false, "ARTEX", "192.0.2.1"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if out := result(t, done); out.Code != TargetDenied {
|
||||
t.Fatalf("denied request ended with %+v", out)
|
||||
}
|
||||
}
|
||||
|
||||
// Task allow rules play no role in the gate: they neither satisfy it nor
|
||||
// restrict it. Block rules, global and of the task, always win.
|
||||
func TestAllowRuleDoesNotSatisfyGate(t *testing.T) {
|
||||
d := targetTestDB(t)
|
||||
ic, res := pgGate(t, d)
|
||||
scope := taskScope(t, d, ic, time.Minute)
|
||||
ref := scope.dbRef(t)
|
||||
res.set("app.example.com", "203.0.113.7")
|
||||
res.set("other.example.com", "203.0.113.9")
|
||||
rule := func(action, kind, pattern string) {
|
||||
t.Helper()
|
||||
if _, err := d.Assets().CreateTaskInterceptRule(ref.TaskID, action, kind, pattern, "target gate test", true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
// An allow rule on the host does not replace the approval.
|
||||
rule("allow", "exact_domain", "app.example.com")
|
||||
reqs, out := ic.CheckTargets(context.Background(), scope, "WebFetch", fetchInput("https://app.example.com/"))
|
||||
if out.Code != "" || len(reqs) != 1 || reqs[0].Reason != "no_pin" {
|
||||
t.Fatalf("allow rule on the host: requests=%+v result=%+v, want a request", reqs, out)
|
||||
}
|
||||
// A pinned host outside the allow list still passes: the whitelist is not
|
||||
// applied at dispatch.
|
||||
pinURL(t, d, ic, scope, "https://other.example.com/")
|
||||
mustPass(t, "pinned host outside the allow list", ic, scope, "https://other.example.com/")
|
||||
|
||||
// A block rule of the task beats the pin.
|
||||
rule("block", "exact_ip", "203.0.113.9")
|
||||
if reqs, out := ic.CheckTargets(context.Background(), scope, "WebFetch", fetchInput("https://other.example.com/")); out.Code != TargetRuleBlocked || len(reqs) != 0 {
|
||||
t.Fatalf("task block rule: requests=%+v result=%+v", reqs, out)
|
||||
}
|
||||
// It is the task's rule: another task is not blocked by it.
|
||||
other := taskScope(t, d, ic, time.Minute)
|
||||
if reqs, out := ic.CheckTargets(context.Background(), other, "WebFetch", fetchInput("https://other.example.com/")); out.Code != "" || len(reqs) != 1 {
|
||||
t.Fatalf("other task: requests=%+v result=%+v, want a request", reqs, out)
|
||||
}
|
||||
// A global block rule applies to every scope.
|
||||
global, err := d.CreateAssetInterceptRule("exact_domain", "app.example.com", "target gate test", true)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = d.DeleteAssetInterceptRule(global.ID) })
|
||||
for name, s := range map[string]TargetScope{"task": scope, "other task": other} {
|
||||
if reqs, out := ic.CheckTargets(context.Background(), s, "WebFetch", fetchInput("https://app.example.com/")); out.Code != TargetRuleBlocked || len(reqs) != 0 {
|
||||
t.Fatalf("global block rule, %s: requests=%+v result=%+v", name, reqs, out)
|
||||
}
|
||||
}
|
||||
// Each block is a decided row; none opened a request.
|
||||
rows := decidedRows(t, d, scope)
|
||||
blocks := 0
|
||||
for _, row := range rows {
|
||||
if row.Audit.Targets[0].Outcome == "rule_blocked" {
|
||||
blocks++
|
||||
if row.Status != "denied" || !strings.HasPrefix(row.Reason, TargetRuleBlocked+": ") || row.Audit.ExecutionStatus != "not_executed" {
|
||||
t.Fatalf("block row = %+v audit=%+v", row.InterceptApprovalRow, row.Audit)
|
||||
}
|
||||
}
|
||||
}
|
||||
if blocks != 2 || len(openRows(t, d, scope)) != 0 {
|
||||
t.Fatalf("%d block rows and %d open requests, want 2 and 0", blocks, len(openRows(t, d, scope)))
|
||||
}
|
||||
}
|
||||
|
||||
// The proxy can be turned on between the approval and a later call. The pin
|
||||
// row then still says name_only_effective = false, and the gate treats it as
|
||||
// name-only at call time: a changed address neither blocks nor asks.
|
||||
func TestProxyTurnedOnAfterApprovalMakesPinNameOnly(t *testing.T) {
|
||||
d := targetTestDB(t)
|
||||
ic, res := pgGate(t, d)
|
||||
proxied := false
|
||||
ic.SetUpstreamProxyCheck(func() bool { return proxied })
|
||||
scope := taskScope(t, d, ic, time.Minute)
|
||||
res.set("app.example.com", "203.0.113.7")
|
||||
pinURL(t, d, ic, scope, "https://app.example.com/")
|
||||
approved := pins(t, d, scope)
|
||||
if len(approved) != 1 || approved[0].NameOnlyEffective || !slices.Equal(approved[0].PinnedIPs, []string{"203.0.113.7"}) {
|
||||
t.Fatalf("pins = %+v, want one pin that is not name-only", approved)
|
||||
}
|
||||
requestRows := allRows(t, d, scope)
|
||||
|
||||
// global_proxy is set, and the name answers another address. A new
|
||||
// interceptor stands for a later call that has nothing cached.
|
||||
proxied = true
|
||||
res.set("app.example.com", "198.51.100.99")
|
||||
ic.SetResolver(res)
|
||||
mustPass(t, "changed address behind the proxy", ic, scope, "https://app.example.com/")
|
||||
|
||||
if n := len(openRows(t, d, scope)); n != 0 {
|
||||
t.Fatalf("%d open requests, want none", n)
|
||||
}
|
||||
if n := allRows(t, d, scope); n != requestRows {
|
||||
t.Fatalf("%d request rows, want %d: the call opened a request", n, requestRows)
|
||||
}
|
||||
after := pins(t, d, scope)
|
||||
if len(after) != 1 || after[0].ID != approved[0].ID || after[0].NameOnlyEffective || after[0].Status != db.TargetPinActive ||
|
||||
!slices.Equal(after[0].PinnedIPs, []string{"203.0.113.7"}) {
|
||||
t.Fatalf("pins after the call = %+v, want the same row, unchanged", after)
|
||||
}
|
||||
// The use of the pin is on record, with the address the platform saw.
|
||||
rows := decidedRows(t, d, scope)
|
||||
last := rows[len(rows)-1]
|
||||
if target := last.Audit.Targets[0]; last.Status != "allowed" || target.Outcome != "pin_match" || target.PinID != approved[0].ID ||
|
||||
!slices.Equal(target.ResolvedIPs, []string{"198.51.100.99"}) {
|
||||
t.Fatalf("last row = %+v audit=%+v", last.InterceptApprovalRow, last.Audit)
|
||||
}
|
||||
|
||||
// Control: with the proxy off the same call asks.
|
||||
proxied = false
|
||||
if reqs, out := ic.CheckTargets(context.Background(), scope, "WebFetch", fetchInput("https://app.example.com/")); out.Code != "" || len(reqs) != 1 || reqs[0].Reason != "new_ip" {
|
||||
t.Fatalf("proxy off: requests=%+v result=%+v, want a new_ip request", reqs, out)
|
||||
}
|
||||
}
|
||||
|
||||
// An approval never pins zero addresses: not on a first approval, and not when
|
||||
// the key already has a pin with addresses.
|
||||
func TestApprovalNeverPinsEmptyAddressList(t *testing.T) {
|
||||
d := targetTestDB(t)
|
||||
ic, res := pgGate(t, d)
|
||||
scope := taskScope(t, d, ic, time.Minute)
|
||||
ref := scope.dbRef(t)
|
||||
|
||||
// RequestTargets refuses to open such a request.
|
||||
for _, ips := range [][]string{nil, {}} {
|
||||
out := ic.RequestTargets(context.Background(), scope, "WebFetch", webFetchInput,
|
||||
[]TargetRequest{{Host: "app.example.com", Port: 443, Kind: TargetDomain, ResolvedIPs: ips, Reason: "no_pin"}}, nil)
|
||||
if out.Code != TargetResolveFailed || out.PendingID != 0 {
|
||||
t.Fatalf("request with %v: %+v, want %s", ips, out, TargetResolveFailed)
|
||||
}
|
||||
}
|
||||
// One empty target refuses the whole call, before any row is written.
|
||||
out := ic.RequestTargets(context.Background(), scope, "Bash", []byte(`{"command":"x"}`),
|
||||
[]TargetRequest{target("a.example.com", 443, "203.0.113.7"), target("b.example.com", 443)}, nil)
|
||||
if out.Code != TargetResolveFailed || out.Host != "b.example.com" || allRows(t, d, scope) != 0 {
|
||||
t.Fatalf("mixed call: %+v with %d rows, want %s and no row", out, allRows(t, d, scope), TargetResolveFailed)
|
||||
}
|
||||
|
||||
// A row that reached the table without an address cannot be approved.
|
||||
open := func(host string, ips []string) int64 {
|
||||
t.Helper()
|
||||
id, created, err := d.FindOrCreateTargetRequest(db.TargetRequest{Scope: ref, Host: host, Port: 443, ToolName: "WebFetch", Reason: "test",
|
||||
Audit: &db.InterceptAudit{Targets: []db.InterceptTarget{{Host: host, Port: 443, ResolvedIPs: ips, Outcome: "requested"}}}})
|
||||
if err != nil || !created {
|
||||
t.Fatalf("open request: id=%d created=%v err=%v", id, created, err)
|
||||
}
|
||||
return id
|
||||
}
|
||||
for _, ips := range [][]string{nil, {}, {""}} {
|
||||
id := open("empty.example.com", ips)
|
||||
if err := ic.Decide(id, true, "ARTEX", "192.0.2.1"); !errors.Is(err, db.ErrTargetPinEmpty) {
|
||||
t.Fatalf("approve %v: err=%v, want ErrTargetPinEmpty", ips, err)
|
||||
}
|
||||
if row, err := d.GetInterceptPending(id); err != nil || row == nil || row.Status != "pending" {
|
||||
t.Fatalf("refused approval: row=%+v err=%v, want it still pending", row, err)
|
||||
}
|
||||
if n := len(pins(t, d, scope)); n != 0 {
|
||||
t.Fatalf("%d pins after a refused approval, want none", n)
|
||||
}
|
||||
// The request can still be denied.
|
||||
if err := ic.Decide(id, false, "ARTEX", "192.0.2.1"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
// With a pin on the key: the approval is refused all the same, and the
|
||||
// pin is neither superseded nor re-issued with its old addresses.
|
||||
res.set("app.example.com", "203.0.113.7")
|
||||
pinURL(t, d, ic, scope, "https://app.example.com/")
|
||||
id := open("app.example.com", nil)
|
||||
if err := ic.Decide(id, true, "ARTEX", "192.0.2.1"); !errors.Is(err, db.ErrTargetPinEmpty) {
|
||||
t.Fatalf("approve an empty new_ip request: err=%v, want ErrTargetPinEmpty", err)
|
||||
}
|
||||
if after := pins(t, d, scope); len(after) != 1 || after[0].Version != 1 || after[0].Status != db.TargetPinActive || !slices.Equal(after[0].PinnedIPs, []string{"203.0.113.7"}) {
|
||||
t.Fatalf("pins = %+v, want version 1 untouched", after)
|
||||
}
|
||||
|
||||
// A name that stops resolving opens no request, so there is nothing to approve.
|
||||
res.errs["app.example.com"] = errors.New("no such host")
|
||||
ic.SetResolver(res)
|
||||
if reqs, out := ic.CheckTargets(context.Background(), scope, "WebFetch", fetchInput("https://app.example.com:8443/")); out.Code != TargetResolveFailed || len(reqs) != 0 {
|
||||
t.Fatalf("unresolvable name: requests=%+v result=%+v", reqs, out)
|
||||
}
|
||||
}
|
||||
|
||||
// A block is logged for a conversation as it is for a task, on the
|
||||
// conversation's own rows.
|
||||
func TestTargetBlockIsLoggedForConversation(t *testing.T) {
|
||||
d := targetTestDB(t)
|
||||
ic, res := pgGate(t, d)
|
||||
conv, err := d.CreateConversation("auto", "target gate block", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_ = d.DeleteConversation(conv.ID)
|
||||
_, _ = d.Exec(`DELETE FROM target_pins WHERE conversation_id=$1`, conv.ID)
|
||||
})
|
||||
scope := TargetScope{ConversationID: conv.ID, Enabled: func() bool { return true }, WaitBound: time.Minute, Interceptor: ic}
|
||||
res.errs["app.example.com"] = errors.New("no such host")
|
||||
ctx := WithCall(WithConvID(context.Background(), conv.ID), "WebFetch", fetchInput("https://app.example.com/"))
|
||||
if reqs, out := ic.CheckTargets(ctx, scope, "WebFetch", fetchInput("https://app.example.com/")); out.Code != TargetResolveFailed || len(reqs) != 0 {
|
||||
t.Fatalf("requests=%+v result=%+v", reqs, out)
|
||||
}
|
||||
rows := decidedRows(t, d, scope)
|
||||
if len(rows) != 1 {
|
||||
t.Fatalf("%d rows, want 1", len(rows))
|
||||
}
|
||||
row := rows[0]
|
||||
if row.Status != "denied" || row.ConversationID == nil || *row.ConversationID != conv.ID || row.TaskID != nil ||
|
||||
row.Audit.Targets[0].Outcome != "resolve_failed" || row.Audit.InputDigest == "" {
|
||||
t.Fatalf("row = %+v audit=%+v", row.InterceptApprovalRow, row.Audit)
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,155 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
package intercept
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Autumn-27/artex/db"
|
||||
)
|
||||
|
||||
// The gate inside the custom tools: GateRenderedURL and GateRenderedCommand
|
||||
// read what the tool is about to run. Without a database: the cases that reach
|
||||
// a request are covered by the server tests.
|
||||
|
||||
func (g *gateFixture) ctx() context.Context {
|
||||
return WithTargetScope(context.Background(), g.scope)
|
||||
}
|
||||
|
||||
// With the gate off, or without a scope (the operator's test endpoint), the
|
||||
// rendered target is not even parsed: nothing is read, resolved or logged, and
|
||||
// a URL the gate could not read passes too, as it did before the gate.
|
||||
func TestRenderedGateOffTouchesNothing(t *testing.T) {
|
||||
g := newGate(t)
|
||||
g.res.set("app.example.com", "203.0.113.7")
|
||||
for name, ctx := range map[string]context.Context{
|
||||
"no scope": context.Background(),
|
||||
"no flag": WithTargetScope(context.Background(), TargetScope{TaskID: "7", WaitBound: time.Minute, Interceptor: g.ic}),
|
||||
"flag off": WithTargetScope(context.Background(), TargetScope{TaskID: "7", Enabled: func() bool { return false }, Interceptor: g.ic}),
|
||||
} {
|
||||
for _, res := range []TargetResult{
|
||||
GateRenderedURL(ctx, "probe", appURL),
|
||||
GateRenderedURL(ctx, "probe", "http://a b/"),
|
||||
GateRenderedCommand(ctx, "probe", "curl "+appURL),
|
||||
} {
|
||||
if res != (TargetResult{}) {
|
||||
t.Errorf("%s: %+v, want nothing", name, res)
|
||||
}
|
||||
}
|
||||
}
|
||||
if g.store.reads != 0 || len(g.store.logged) != 0 || g.res.total() != 0 {
|
||||
t.Errorf("gate off: %d store reads, %d rows, %d lookups, want none", g.store.reads, len(g.store.logged), g.res.total())
|
||||
}
|
||||
}
|
||||
|
||||
// The target of a rendered URL is read as net/http dials it: the host of the
|
||||
// parsed URL, the explicit port, else the default of the scheme.
|
||||
func TestRenderedURLTarget(t *testing.T) {
|
||||
for _, c := range []struct {
|
||||
raw string
|
||||
want Target
|
||||
}{
|
||||
{"https://App.Example.COM./x?next=http://other.example/", Target{TargetDomain, "app.example.com", 443}},
|
||||
{"http://user:pw@203.0.113.7:8080/a", Target{TargetIP, "203.0.113.7", 8080}},
|
||||
{"http://[::ffff:203.0.113.7]/", Target{TargetIP, "203.0.113.7", 80}},
|
||||
{"http://[2001:db8::1]:8443/", Target{TargetIP, "2001:db8::1", 8443}},
|
||||
{"http://2130706433/", Target{TargetIP, "127.0.0.1", 80}},
|
||||
{"HTTPS://bücher.example/", Target{TargetDomain, "xn--bcher-kva.example", 443}},
|
||||
// The client's reading of the authority, not the shell's: a.example; is userinfo.
|
||||
{"http://a.example;@b.example/", Target{TargetDomain, "b.example", 80}},
|
||||
} {
|
||||
got, err := urlTarget(c.raw)
|
||||
if err != nil || got != c.want {
|
||||
t.Errorf("%q: %+v, %v; want %+v", c.raw, got, err, c.want)
|
||||
}
|
||||
}
|
||||
for _, raw := range []string{
|
||||
"http://a b/", // not a URL the client accepts
|
||||
`http://a.example\@b.example/`, // idem
|
||||
"http://[::1/", // idem
|
||||
"http:///path", // no host
|
||||
"app.example.com/path", // no scheme: no host
|
||||
"http://app.example.com:99999/x", // no valid port
|
||||
} {
|
||||
if got, err := urlTarget(raw); err == nil {
|
||||
t.Errorf("%q: %+v, want no target", raw, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A rendered URL whose target cannot be read is blocked: no lookup, no
|
||||
// request, one decided row that records the URL.
|
||||
func TestRenderedURLWithoutTargetFailsClosed(t *testing.T) {
|
||||
g := newGate(t)
|
||||
for n, raw := range []string{"http://a b/", "http:///path", "app.example.com/path", "http://app.example.com:99999/"} {
|
||||
res := GateRenderedURL(g.ctx(), "probe_http", raw)
|
||||
if res.Code != TargetGateUnavailable || !strings.Contains(res.Reason, "could not be read") {
|
||||
t.Fatalf("%q: %+v, want %s", raw, res, TargetGateUnavailable)
|
||||
}
|
||||
if len(g.store.logged) != n+1 {
|
||||
t.Fatalf("%q: %d rows, want %d", raw, len(g.store.logged), n+1)
|
||||
}
|
||||
row := g.store.logged[n]
|
||||
var input map[string]string
|
||||
_ = json.Unmarshal(row.ToolInput, &input)
|
||||
if row.Status != "denied" || row.ToolName != "probe_http" || input["url"] != raw || row.Audit.Targets[0].Outcome != "gate_unavailable" {
|
||||
t.Fatalf("%q: row = %+v (input %s)", raw, row, row.ToolInput)
|
||||
}
|
||||
}
|
||||
if g.res.total() != 0 {
|
||||
t.Fatalf("%d lookups, want none", g.res.total())
|
||||
}
|
||||
}
|
||||
|
||||
// A gated scope without the interceptor that owns the gate cannot decide: the
|
||||
// call is blocked, never let through.
|
||||
func TestRenderedGateWithoutInterceptorFailsClosed(t *testing.T) {
|
||||
ctx := WithTargetScope(context.Background(), TargetScope{TaskID: "7", Enabled: func() bool { return true }})
|
||||
for name, res := range map[string]TargetResult{
|
||||
"url": GateRenderedURL(ctx, "probe", appURL),
|
||||
"unreadable url": GateRenderedURL(ctx, "probe", "http://a b/"),
|
||||
"command": GateRenderedCommand(ctx, "probe", "curl "+appURL),
|
||||
} {
|
||||
if res.Code != TargetGateUnavailable {
|
||||
t.Errorf("%s: %+v, want %s", name, res, TargetGateUnavailable)
|
||||
}
|
||||
}
|
||||
// A command that names no target needs no gate.
|
||||
if res := GateRenderedCommand(ctx, "probe", "ls -la"); res != (TargetResult{}) {
|
||||
t.Errorf("no target: %+v, want nothing", res)
|
||||
}
|
||||
}
|
||||
|
||||
// The rendered target goes through the same decision as a target at the hook:
|
||||
// block rules, resolution, pins. A pinned target passes, a blocked one is
|
||||
// blocked without a request.
|
||||
func TestRenderedTargetUsesTheGateDecision(t *testing.T) {
|
||||
g := newGate(t)
|
||||
g.res.set("app.example.com", "203.0.113.7")
|
||||
g.store.approve(TargetRequest{Host: "app.example.com", Port: 443, ResolvedIPs: []string{"203.0.113.7"}})
|
||||
g.store.approve(TargetRequest{Host: "198.51.100.9", Port: 8080, ResolvedIPs: []string{"198.51.100.9"}})
|
||||
|
||||
for name, res := range map[string]TargetResult{
|
||||
"url": GateRenderedURL(g.ctx(), "probe", appURL),
|
||||
"url, ip and port": GateRenderedURL(g.ctx(), "probe", "http://198.51.100.9:8080/x"),
|
||||
"command": GateRenderedCommand(g.ctx(), "probe", "curl -s '"+appURL+"'"),
|
||||
"command, no target": GateRenderedCommand(g.ctx(), "probe", "nmap -sV app.example.com"),
|
||||
"command, ip literal": GateRenderedCommand(g.ctx(), "probe", "nc 198.51.100.9:8080"),
|
||||
} {
|
||||
if res != (TargetResult{}) {
|
||||
t.Errorf("%s: %+v, want the call to pass", name, res)
|
||||
}
|
||||
}
|
||||
|
||||
g.res.errs["down.example"] = context.DeadlineExceeded
|
||||
if res := GateRenderedURL(g.ctx(), "probe", "https://down.example/"); res.Code != TargetResolveFailed || res.Host != "down.example" {
|
||||
t.Fatalf("unresolvable: %+v", res)
|
||||
}
|
||||
g.store.rules = []db.AssetInterceptRule{{Enabled: true, Kind: "exact_domain", Pattern: "app.example.com"}}
|
||||
for name, res := range map[string]TargetResult{
|
||||
"url": GateRenderedURL(g.ctx(), "probe", appURL),
|
||||
"command": GateRenderedCommand(g.ctx(), "probe", "curl "+appURL),
|
||||
} {
|
||||
if res.Code != TargetRuleBlocked || res.Host != "app.example.com" || res.Port != 443 {
|
||||
t.Errorf("%s: %+v, want %s on the pinned host", name, res, TargetRuleBlocked)
|
||||
}
|
||||
}
|
||||
if !strings.Contains(strings.Join(g.store.outcomes(), " "), "denied/rule_blocked") {
|
||||
t.Fatalf("rows = %v, want the block logged", g.store.outcomes())
|
||||
}
|
||||
}
|
||||
|
||||
// The shell quoting of a parameter inside a host name hides the name from the
|
||||
// command-string rules; the reading without the quoting is read as well.
|
||||
func TestRenderedCommandReadsTheUnquotedRendering(t *testing.T) {
|
||||
g := newGate(t)
|
||||
g.store.rules = []db.AssetInterceptRule{{Enabled: true, Kind: "exact_domain", Pattern: "api.example.com"}}
|
||||
quoted := "curl http://'api'.example.com:8080/"
|
||||
if res := GateRenderedCommand(g.ctx(), "probe", quoted); res != (TargetResult{}) {
|
||||
t.Fatalf("quoted only: %+v; the rules were expected to miss the name", res)
|
||||
}
|
||||
res := GateRenderedCommand(g.ctx(), "probe", quoted, "curl http://api.example.com:8080/")
|
||||
if res.Code != TargetRuleBlocked || res.Host != "api.example.com" || res.Port != 8080 {
|
||||
t.Fatalf("with the unquoted reading: %+v, want %s on api.example.com:8080", res, TargetRuleBlocked)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,383 @@
|
||||
package intercept
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/Autumn-27/artex/db"
|
||||
)
|
||||
|
||||
// Block codes a target request can end with. They are carried in the
|
||||
// model-facing text of the blocked call.
|
||||
const (
|
||||
// TargetApprovalPending: no decision within the wait bound. The request
|
||||
// stays open, so a later approval still creates the pin.
|
||||
TargetApprovalPending = "TARGET_APPROVAL_PENDING"
|
||||
// TargetDenied: the request was closed without an approval.
|
||||
TargetDenied = "TARGET_DENIED"
|
||||
// TargetGateUnavailable: the request could not be written or read.
|
||||
TargetGateUnavailable = "TARGET_GATE_UNAVAILABLE"
|
||||
)
|
||||
|
||||
// targetPollInterval is how often a waiter re-reads its request row. The poll
|
||||
// covers a decision taken on another instance that shares the database.
|
||||
var targetPollInterval = 2 * time.Second
|
||||
|
||||
// TargetRequest is one target of a gated call that no pin covers.
|
||||
type TargetRequest struct {
|
||||
Host string // normalised name, IP literal or CIDR; with Port the key of the request
|
||||
Port int // 0 = port not declared
|
||||
Kind string
|
||||
// ResolvedIPs are the addresses resolved for this request. They are what the
|
||||
// human reads and what an approval pins.
|
||||
ResolvedIPs []string
|
||||
Resolver string
|
||||
ResolvedAt time.Time
|
||||
Reason string // no_pin | new_ip | revoked
|
||||
Pin *db.TargetPin // the active pin an approval would extend (new_ip); nil otherwise
|
||||
ExemptVoid bool // a stored exemption names the target and was void for this call
|
||||
}
|
||||
|
||||
// TargetResult is the outcome of RequestTargets. Code is empty when every
|
||||
// request the call waited on was approved (or was decided in between): the
|
||||
// caller then re-runs the pin lookup and executes only if every target is
|
||||
// covered. Otherwise Code is the block code and Host, Port and PendingID name
|
||||
// the request that stopped the call.
|
||||
type TargetResult struct {
|
||||
Code string
|
||||
Host string
|
||||
Port int
|
||||
PendingID int64
|
||||
Reason string
|
||||
// AskMerged reports that the ask passed in was carried by a request this call
|
||||
// created, so its decision answered both. When false the caller still has to
|
||||
// raise the ask on its own.
|
||||
AskMerged bool
|
||||
}
|
||||
|
||||
func (s TargetScope) ref() (db.TargetScopeRef, error) {
|
||||
ref := db.TargetScopeRef{ConversationID: s.ConversationID}
|
||||
if s.TaskID != "" {
|
||||
id, err := strconv.ParseInt(s.TaskID, 10, 64)
|
||||
if err != nil {
|
||||
return ref, fmt.Errorf("target scope: task id %q: %w", s.TaskID, err)
|
||||
}
|
||||
ref.TaskID = id
|
||||
}
|
||||
return ref, nil
|
||||
}
|
||||
|
||||
// scopeName is how a request text names what an approval is bound to.
|
||||
func (s TargetScope) scopeName() string {
|
||||
if s.TaskID != "" {
|
||||
return "task " + s.TaskID
|
||||
}
|
||||
return "conversation " + strconv.FormatInt(s.ConversationID, 10)
|
||||
}
|
||||
|
||||
// targetWaiter is one request row a call waits on.
|
||||
type targetWaiter struct {
|
||||
target TargetRequest
|
||||
id int64 // 0 = the open request was decided in between: nothing to wait for
|
||||
created bool // this call wrote the row
|
||||
}
|
||||
|
||||
// RequestTargets asks a human to approve the targets of one gated call that no
|
||||
// pin covers, and waits for the decisions up to the scope's wait bound.
|
||||
//
|
||||
// There is one open request per (scope, host, port). The first call writes the
|
||||
// row and emits the approval card once; later calls for the same key attach to
|
||||
// that row and wait on it. A request is durable and host-level: a call that
|
||||
// stops waiting (bound elapsed, cancelled) leaves it open, and a later approval
|
||||
// still creates the pin. Nothing here ever pins: only Decide does, on a click.
|
||||
//
|
||||
// ask is the pending ask of a rule or of the judge for the same call, nil when
|
||||
// there is none. It is merged into the first row this call creates, so one
|
||||
// click answers both. A merged row is bound to its creating call like a classic
|
||||
// ask: when that call leaves, the system settles it, and a rule timeout never
|
||||
// allows it, because a timeout cannot pin.
|
||||
func (i *Interceptor) RequestTargets(ctx context.Context, scope TargetScope, toolName string, input []byte, targets []TargetRequest, ask *Decision) TargetResult {
|
||||
ref, err := scope.ref()
|
||||
if err != nil {
|
||||
return TargetResult{Code: TargetGateUnavailable, Reason: err.Error()}
|
||||
}
|
||||
// An approval pins the addresses the request displays, so a request without
|
||||
// any is never opened: approving it would pin nothing.
|
||||
for _, t := range targets {
|
||||
if len(t.ResolvedIPs) == 0 {
|
||||
return TargetResult{Code: TargetResolveFailed, Host: t.Host, Port: t.Port,
|
||||
Reason: "no address was resolved for " + targetName(t) + ", and an approval never pins an empty address list"}
|
||||
}
|
||||
}
|
||||
if scope.WaitBound <= 0 {
|
||||
// A call that never waits cannot own a call-bound row.
|
||||
ask = nil
|
||||
}
|
||||
|
||||
var res TargetResult
|
||||
var merged *targetWaiter
|
||||
var mergedAsk Decision
|
||||
// Whatever the way out, a merged row never outlives its creating call. This
|
||||
// is a no-op once the row is decided.
|
||||
defer func() {
|
||||
if merged != nil {
|
||||
i.settleMerged(merged.id, "denied", "the call left before a decision")
|
||||
}
|
||||
}()
|
||||
|
||||
waiters := make([]*targetWaiter, 0, len(targets))
|
||||
for _, t := range targets {
|
||||
w, err := i.openTargetRequest(ctx, ref, scope, toolName, input, t, ask)
|
||||
if err != nil {
|
||||
res.Code, res.Host, res.Port, res.Reason = TargetGateUnavailable, t.Host, t.Port, err.Error()
|
||||
return res
|
||||
}
|
||||
if w.created && ask != nil {
|
||||
merged, mergedAsk, res.AskMerged = w, *ask, true
|
||||
ask = nil
|
||||
}
|
||||
waiters = append(waiters, w)
|
||||
}
|
||||
if len(waiters) == 0 {
|
||||
return res
|
||||
}
|
||||
|
||||
blocked := func(w *targetWaiter, code, reason string) TargetResult {
|
||||
res.Code, res.Host, res.Port, res.PendingID, res.Reason = code, w.target.Host, w.target.Port, w.id, reason
|
||||
return res
|
||||
}
|
||||
if scope.WaitBound <= 0 {
|
||||
return blocked(waiters[0], TargetApprovalPending, "this call does not wait for approvals; the request stays open")
|
||||
}
|
||||
bound := time.NewTimer(scope.WaitBound)
|
||||
defer bound.Stop()
|
||||
|
||||
// The merged row first: its rule timeout runs from the creation of the row.
|
||||
if merged != nil {
|
||||
var ruleTimeout <-chan time.Time
|
||||
if mergedAsk.TimeoutEnabled {
|
||||
secs := mergedAsk.TimeoutSeconds
|
||||
if secs <= 0 {
|
||||
secs = 60
|
||||
}
|
||||
timer := time.NewTimer(time.Duration(secs) * time.Second)
|
||||
defer timer.Stop()
|
||||
ruleTimeout = timer.C
|
||||
}
|
||||
status, reason := "", ""
|
||||
switch st := i.awaitTarget(ctx, merged.id, bound.C, ruleTimeout); st {
|
||||
case "allowed":
|
||||
case waitRuleTimeout:
|
||||
status, reason = "timeout", "approval timed out"
|
||||
if mergedAsk.TimeoutAction == "allow" {
|
||||
reason += `; the timeout action "allow" was overridden, because a timeout cannot pin a target`
|
||||
}
|
||||
case waitElapsed:
|
||||
status, reason = "timeout", "no approval within the wait bound"
|
||||
case waitCancelled:
|
||||
status, reason = "denied", "work cancelled"
|
||||
case waitGone:
|
||||
return blocked(merged, TargetGateUnavailable, "the approval request no longer exists")
|
||||
default:
|
||||
return blocked(merged, TargetDenied, "the request was denied")
|
||||
}
|
||||
// A human decision that lands while the call is leaving still counts.
|
||||
if status != "" && (i.settleMerged(merged.id, status, reason) || !i.targetAllowed(merged.id)) {
|
||||
return blocked(merged, TargetDenied, reason)
|
||||
}
|
||||
}
|
||||
|
||||
for _, w := range waiters {
|
||||
if w == merged || w.id == 0 {
|
||||
continue
|
||||
}
|
||||
st := i.awaitTarget(ctx, w.id, bound.C, nil)
|
||||
if (st == "denied" || st == "timeout") && !w.created && i.wasMerged(w.id) {
|
||||
// The row this call attached to was bound to another call and closed
|
||||
// with it. That says nothing about the host: ask once more, with a
|
||||
// plain request.
|
||||
retry, err := i.openTargetRequest(ctx, ref, scope, toolName, input, w.target, nil)
|
||||
if err != nil {
|
||||
return blocked(w, TargetGateUnavailable, err.Error())
|
||||
}
|
||||
if retry.id == 0 {
|
||||
continue
|
||||
}
|
||||
w = retry
|
||||
st = i.awaitTarget(ctx, w.id, bound.C, nil)
|
||||
}
|
||||
switch st {
|
||||
case "allowed":
|
||||
case waitElapsed:
|
||||
return blocked(w, TargetApprovalPending, "no approval within the wait bound; the request stays open")
|
||||
case waitCancelled:
|
||||
return blocked(w, TargetApprovalPending, "work cancelled; the request stays open")
|
||||
case waitGone:
|
||||
return blocked(w, TargetGateUnavailable, "the approval request no longer exists")
|
||||
default:
|
||||
return blocked(w, TargetDenied, "the request was denied")
|
||||
}
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
// openTargetRequest finds or creates the request row for one target. When this
|
||||
// call writes the row it emits the approval card; an attaching call writes and
|
||||
// emits nothing, and never rewrites the addresses a human may be reading.
|
||||
func (i *Interceptor) openTargetRequest(ctx context.Context, ref db.TargetScopeRef, scope TargetScope, toolName string, input []byte, t TargetRequest, ask *Decision) (*targetWaiter, error) {
|
||||
i.mu.RLock()
|
||||
proxied := i.upstreamProxy
|
||||
i.mu.RUnlock()
|
||||
// Behind the upstream proxy the proxy chooses the address: the approval is
|
||||
// for the name, and the pin created from this row says so.
|
||||
nameOnly := proxied != nil && proxied()
|
||||
|
||||
dec := Decision{Action: "ask"}
|
||||
if ask != nil {
|
||||
dec = *ask
|
||||
}
|
||||
dec.Message = targetReason(scope, t, ask, nameOnly)
|
||||
audit := auditFor(ctx, dec, input, "pending")
|
||||
audit.MergedAsk = ask != nil
|
||||
target := db.InterceptTarget{
|
||||
Host: t.Host, Port: t.Port, Kind: t.Kind,
|
||||
ResolvedIPs: t.ResolvedIPs, Resolver: t.Resolver, ResolvedAt: t.ResolvedAt,
|
||||
Reason: t.Reason, Outcome: "requested", NameOnlyEffective: nameOnly, ExemptVoid: t.ExemptVoid,
|
||||
}
|
||||
if t.Pin != nil {
|
||||
target.PinID, target.PinVersion, target.PinnedIPs = t.Pin.ID, t.Pin.Version, t.Pin.PinnedIPs
|
||||
}
|
||||
audit.Targets = []db.InterceptTarget{target}
|
||||
|
||||
_, agentName := taskInfoFromCtx(ctx)
|
||||
id, created, err := i.db.FindOrCreateTargetRequest(db.TargetRequest{
|
||||
Scope: ref, Host: t.Host, Port: t.Port, RuleID: dec.RuleID,
|
||||
AgentName: agentName, ToolName: toolName, ToolInput: input, Reason: dec.Message, Audit: audit,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
w := &targetWaiter{target: t, id: id, created: created}
|
||||
if !created {
|
||||
return w, nil
|
||||
}
|
||||
if ask != nil {
|
||||
// Call-bound like a classic ask: the result of this call belongs to the row.
|
||||
i.bindResult(ctx, id, audit)
|
||||
}
|
||||
detail, _ := json.Marshal(map[string]any{
|
||||
"pending_id": id,
|
||||
"tool": toolName,
|
||||
"input": json.RawMessage(input),
|
||||
})
|
||||
activity := db.Activity{
|
||||
Kind: "intercept_request",
|
||||
Summary: fmt.Sprintf("tool %s requests approval for target %s (#%d)", toolName, targetName(t), id),
|
||||
Detail: string(detail),
|
||||
}
|
||||
if scope.ConversationID != 0 {
|
||||
_, _ = i.db.AppendConvActivity(scope.ConversationID, activity)
|
||||
} else if emit := taskEmitFromCtx(ctx); emit != nil {
|
||||
emit(activity)
|
||||
}
|
||||
return w, nil
|
||||
}
|
||||
|
||||
// Ways a wait ends without a decision on the row.
|
||||
const (
|
||||
waitElapsed = "wait_elapsed" // the scope's wait bound
|
||||
waitRuleTimeout = "wait_rule_timeout" // the timeout of a merged ask
|
||||
waitCancelled = "wait_cancelled" // the call's context
|
||||
waitGone = "wait_gone" // the row no longer exists (its conversation was deleted)
|
||||
)
|
||||
|
||||
// awaitTarget waits for the decision on one request row and returns its status
|
||||
// (allowed | denied | timeout) or the way the wait ended. It never settles the
|
||||
// row. The row is re-read before each wait: that covers a decision taken before
|
||||
// the waiter registered, and one taken on another instance.
|
||||
func (i *Interceptor) awaitTarget(ctx context.Context, id int64, bound, ruleTimeout <-chan time.Time) string {
|
||||
ch := i.pending.add(id)
|
||||
defer i.pending.remove(id, ch)
|
||||
poll := time.NewTicker(targetPollInterval)
|
||||
defer poll.Stop()
|
||||
for {
|
||||
row, err := i.db.GetInterceptPending(id)
|
||||
if err == nil && row == nil {
|
||||
return waitGone
|
||||
}
|
||||
if err == nil && row.Status != "pending" {
|
||||
return row.Status
|
||||
}
|
||||
select {
|
||||
case <-ch:
|
||||
case <-poll.C:
|
||||
case <-bound:
|
||||
return waitElapsed
|
||||
case <-ruleTimeout:
|
||||
return waitRuleTimeout
|
||||
case <-ctx.Done():
|
||||
return waitCancelled
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// settleMerged closes a merged row on behalf of its creating call, which is
|
||||
// leaving, and wakes the calls attached to it. It reports whether the row was
|
||||
// still open; a merged row closed this way never pins.
|
||||
func (i *Interceptor) settleMerged(id int64, status, reason string) bool {
|
||||
resolved, err := i.db.ResolveIntercept(id, status, "deny", reason, "system")
|
||||
if err != nil || !resolved {
|
||||
return false
|
||||
}
|
||||
i.pending.resolve(id, false)
|
||||
return true
|
||||
}
|
||||
|
||||
func (i *Interceptor) targetAllowed(id int64) bool {
|
||||
row, err := i.db.GetInterceptPending(id)
|
||||
return err == nil && row != nil && row.Status == "allowed"
|
||||
}
|
||||
|
||||
func (i *Interceptor) wasMerged(id int64) bool {
|
||||
detail, err := i.db.GetInterceptDetail(id)
|
||||
return err == nil && detail != nil && detail.Audit != nil && detail.Audit.MergedAsk
|
||||
}
|
||||
|
||||
func targetName(t TargetRequest) string {
|
||||
if t.Port == 0 {
|
||||
return t.Host
|
||||
}
|
||||
return net.JoinHostPort(t.Host, strconv.Itoa(t.Port))
|
||||
}
|
||||
|
||||
// targetReason is the text a human reads before deciding: host, port, the
|
||||
// addresses an approval pins, and what the approval is bound to.
|
||||
func targetReason(scope TargetScope, t TargetRequest, ask *Decision, nameOnly bool) string {
|
||||
var b strings.Builder
|
||||
if ask != nil && ask.Message != "" {
|
||||
b.WriteString(ask.Message)
|
||||
b.WriteString("\nThis call also names a target that needs approval. ")
|
||||
}
|
||||
name, bound := targetName(t), scope.scopeName()
|
||||
if t.Port == 0 {
|
||||
name += " (port not declared: every port)"
|
||||
}
|
||||
ips := strings.Join(t.ResolvedIPs, ", ")
|
||||
if t.Pin != nil {
|
||||
fmt.Fprintf(&b, "Target %s is pinned for %s (pin v%d: %s) and now resolves to %s (resolver: %s).",
|
||||
name, bound, t.Pin.Version, strings.Join(t.Pin.PinnedIPs, ", "), ips, t.Resolver)
|
||||
} else {
|
||||
fmt.Fprintf(&b, "Target %s is not approved for %s. It resolves to %s (resolver: %s).", name, bound, ips, t.Resolver)
|
||||
}
|
||||
fmt.Fprintf(&b, " Approving pins these addresses for %s until revoked.", bound)
|
||||
if nameOnly {
|
||||
b.WriteString(" Egress goes through the upstream proxy, which chooses the address it connects to." +
|
||||
" The addresses listed are what the platform resolved. This approval is name-only.")
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
@@ -0,0 +1,754 @@
|
||||
package intercept
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Autumn-27/artex/db"
|
||||
)
|
||||
|
||||
func TestPendingWakesAllWaiters(t *testing.T) {
|
||||
p := newPendingManager()
|
||||
a, b, gone := p.add(1), p.add(1), p.add(1)
|
||||
other := p.add(2)
|
||||
p.remove(1, gone)
|
||||
p.resolve(1, true)
|
||||
for name, ch := range map[string]chan bool{"first": a, "second": b} {
|
||||
select {
|
||||
case allowed := <-ch:
|
||||
if !allowed {
|
||||
t.Fatalf("%s waiter got a denial", name)
|
||||
}
|
||||
default:
|
||||
t.Fatalf("%s waiter of the request was not woken", name)
|
||||
}
|
||||
}
|
||||
select {
|
||||
case <-gone:
|
||||
t.Fatal("a removed waiter was woken")
|
||||
case <-other:
|
||||
t.Fatal("a waiter of another request was woken")
|
||||
default:
|
||||
}
|
||||
if len(p.ch) != 1 || len(p.ch[2]) != 1 {
|
||||
t.Fatalf("waiters left = %v, want only the one of request 2", p.ch)
|
||||
}
|
||||
// Resolving a request nobody waits on, twice, blocks nobody.
|
||||
p.resolve(1, true)
|
||||
p.resolve(2, false)
|
||||
if allowed := <-other; allowed {
|
||||
t.Fatal("the waiter of request 2 got an approval")
|
||||
}
|
||||
p.remove(2, other)
|
||||
if len(p.ch) != 0 {
|
||||
t.Fatalf("waiters left = %v, want none", p.ch)
|
||||
}
|
||||
}
|
||||
|
||||
func targetTestDB(t *testing.T) *db.DB {
|
||||
t.Helper()
|
||||
dsn, _, err := db.DSN()
|
||||
if err != nil {
|
||||
t.Skipf("no database config (%v) - skipping", err)
|
||||
}
|
||||
d, err := db.Open(dsn)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { d.Close() })
|
||||
return d
|
||||
}
|
||||
|
||||
// taskScope creates a task and returns its target scope on ic, with the gate
|
||||
// on and the given wait bound.
|
||||
func taskScope(t *testing.T, d *db.DB, ic *Interceptor, wait time.Duration) TargetScope {
|
||||
t.Helper()
|
||||
task, err := d.CreateTaskWithOptions("target requests", "request, wait, decide", db.TaskCreateOptions{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
id := strconv.FormatInt(task.ID, 10)
|
||||
t.Cleanup(func() {
|
||||
_ = d.DeleteTask(task.ID)
|
||||
_, _ = d.Exec(`DELETE FROM target_pins WHERE task_id=$1`, task.ID)
|
||||
_, _ = d.Exec(`DELETE FROM intercept_pending WHERE task_id=$1`, id)
|
||||
})
|
||||
return TargetScope{TaskID: id, Enabled: func() bool { return true }, WaitBound: wait, Interceptor: ic}
|
||||
}
|
||||
|
||||
func (s TargetScope) dbRef(t *testing.T) db.TargetScopeRef {
|
||||
t.Helper()
|
||||
ref, err := s.ref()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return ref
|
||||
}
|
||||
|
||||
func target(host string, port int, ips ...string) TargetRequest {
|
||||
return TargetRequest{Host: host, Port: port, Kind: "domain", ResolvedIPs: ips, Resolver: "system", ResolvedAt: time.Now().UTC(), Reason: "no_pin"}
|
||||
}
|
||||
|
||||
var webFetchInput = []byte(`{"url":"https://app.example.com/"}`)
|
||||
|
||||
// request runs RequestTargets in the background and returns the channel its
|
||||
// result arrives on.
|
||||
func request(ctx context.Context, ic *Interceptor, scope TargetScope, ask *Decision, targets ...TargetRequest) <-chan TargetResult {
|
||||
out := make(chan TargetResult, 1)
|
||||
go func() { out <- ic.RequestTargets(ctx, scope, "WebFetch", webFetchInput, targets, ask) }()
|
||||
return out
|
||||
}
|
||||
|
||||
func result(t *testing.T, ch <-chan TargetResult) TargetResult {
|
||||
t.Helper()
|
||||
select {
|
||||
case res := <-ch:
|
||||
return res
|
||||
case <-time.After(20 * time.Second):
|
||||
t.Fatal("the call is still waiting")
|
||||
return TargetResult{}
|
||||
}
|
||||
}
|
||||
|
||||
func stillWaiting(t *testing.T, ch <-chan TargetResult) {
|
||||
t.Helper()
|
||||
select {
|
||||
case res := <-ch:
|
||||
t.Fatalf("the call returned %+v, want it still waiting", res)
|
||||
case <-time.After(4 * targetPollInterval):
|
||||
}
|
||||
}
|
||||
|
||||
func eventually(t *testing.T, what string, cond func() bool) {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(20 * time.Second)
|
||||
for !cond() {
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatalf("timed out waiting for %s", what)
|
||||
}
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
|
||||
// openRows returns the ids of the open target requests of scope, oldest first.
|
||||
func openRows(t *testing.T, d *db.DB, scope TargetScope) []int64 {
|
||||
t.Helper()
|
||||
rows, err := d.Query(`SELECT id FROM intercept_pending
|
||||
WHERE COALESCE(task_id,'')=$1 AND COALESCE(conversation_id,0)=$2 AND status='pending' AND target_key IS NOT NULL ORDER BY id`,
|
||||
scope.TaskID, scope.ConversationID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer rows.Close()
|
||||
var ids []int64
|
||||
for rows.Next() {
|
||||
var id int64
|
||||
if err := rows.Scan(&id); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ids = append(ids, id)
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
func allRows(t *testing.T, d *db.DB, scope TargetScope) int {
|
||||
t.Helper()
|
||||
var n int
|
||||
if err := d.QueryRow(`SELECT count(*) FROM intercept_pending
|
||||
WHERE COALESCE(task_id,'')=$1 AND COALESCE(conversation_id,0)=$2 AND target_key IS NOT NULL`, scope.TaskID, scope.ConversationID).Scan(&n); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// waiters is how many calls wait on request id through ic.
|
||||
func waiters(ic *Interceptor, id int64) int {
|
||||
ic.pending.mu.Lock()
|
||||
defer ic.pending.mu.Unlock()
|
||||
return len(ic.pending.ch[id])
|
||||
}
|
||||
|
||||
// oneOpenRow waits for the scope's single open request and returns its id.
|
||||
func oneOpenRow(t *testing.T, d *db.DB, scope TargetScope) int64 {
|
||||
t.Helper()
|
||||
var ids []int64
|
||||
eventually(t, "one open target request", func() bool {
|
||||
ids = openRows(t, d, scope)
|
||||
return len(ids) == 1
|
||||
})
|
||||
return ids[0]
|
||||
}
|
||||
|
||||
func pins(t *testing.T, d *db.DB, scope TargetScope) []*db.TargetPin {
|
||||
t.Helper()
|
||||
out, err := d.ListTargetPins(scope.dbRef(t))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Calls for one host share one request, in one process and across two
|
||||
// instances on one database: one row, one card, one decision wakes them all,
|
||||
// one pin.
|
||||
func TestConcurrentCallsSameHostShareOneRequest(t *testing.T) {
|
||||
d := targetTestDB(t)
|
||||
first, second := New(d), New(targetTestDB(t))
|
||||
scope := taskScope(t, d, first, time.Minute)
|
||||
var cards atomic.Int32
|
||||
ctx := WithTaskContext(context.Background(), scope.TaskID, "work#1", func(a db.Activity) {
|
||||
if a.Kind == "intercept_request" {
|
||||
cards.Add(1)
|
||||
}
|
||||
})
|
||||
tgt := target("app.example.com", 443, "203.0.113.7")
|
||||
a := request(ctx, first, scope, nil, tgt)
|
||||
id := oneOpenRow(t, d, scope)
|
||||
b := request(ctx, first, scope, nil, tgt)
|
||||
c := request(ctx, second, scope, nil, tgt)
|
||||
eventually(t, "every call to wait on the request", func() bool { return waiters(first, id) == 2 && waiters(second, id) == 1 })
|
||||
if ids := openRows(t, d, scope); len(ids) != 1 || allRows(t, d, scope) != 1 {
|
||||
t.Fatalf("open requests = %v, want the one row %d", ids, id)
|
||||
}
|
||||
if n := cards.Load(); n != 1 {
|
||||
t.Fatalf("%d approval cards emitted, want 1", n)
|
||||
}
|
||||
stillWaiting(t, a)
|
||||
|
||||
// Decided on the second instance: its own waiter is woken by the channel,
|
||||
// the waiters of the first instance by their poll.
|
||||
if err := second.Decide(id, true, "ARTEX", "198.51.100.9"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for name, ch := range map[string]<-chan TargetResult{"creator": a, "attached": b, "other instance": c} {
|
||||
if res := result(t, ch); res.Code != "" {
|
||||
t.Fatalf("%s call: %+v, want approved", name, res)
|
||||
}
|
||||
}
|
||||
got := pins(t, d, scope)
|
||||
if len(got) != 1 || got[0].Status != db.TargetPinActive || got[0].Host != "app.example.com" || got[0].Port != 443 ||
|
||||
!slices.Equal(got[0].PinnedIPs, []string{"203.0.113.7"}) {
|
||||
t.Fatalf("pins = %+v, want one active pin", got)
|
||||
}
|
||||
if allRows(t, d, scope) != 1 || cards.Load() != 1 {
|
||||
t.Fatalf("rows=%d cards=%d after the decision, want 1 and 1", allRows(t, d, scope), cards.Load())
|
||||
}
|
||||
if err := first.Decide(id, false, "ARTEX", ""); err != ErrAlreadyDecided {
|
||||
t.Fatalf("second decision: %v, want ErrAlreadyDecided", err)
|
||||
}
|
||||
}
|
||||
|
||||
// A call that stops waiting leaves the request alone: it stays open, the next
|
||||
// call attaches to it, and a later approval still pins.
|
||||
func TestWaiterLeavingKeepsRequestOpen(t *testing.T) {
|
||||
d := targetTestDB(t)
|
||||
ic := New(d)
|
||||
scope := taskScope(t, d, ic, time.Minute)
|
||||
tgt := target("app.example.com", 443, "203.0.113.7")
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
call := request(ctx, ic, scope, nil, tgt)
|
||||
id := oneOpenRow(t, d, scope)
|
||||
eventually(t, "the call to wait", func() bool { return waiters(ic, id) == 1 })
|
||||
cancel()
|
||||
res := result(t, call)
|
||||
if res.Code != TargetApprovalPending || res.PendingID != id || res.Host != "app.example.com" || res.Port != 443 {
|
||||
t.Fatalf("cancelled call: %+v, want %s on request %d", res, TargetApprovalPending, id)
|
||||
}
|
||||
row, err := d.GetInterceptPending(id)
|
||||
if err != nil || row == nil || row.Status != "pending" || row.DecidedBy != "" {
|
||||
t.Fatalf("request after its waiter left = %+v, %v; want untouched", row, err)
|
||||
}
|
||||
if waiters(ic, id) != 0 {
|
||||
t.Fatal("the waiter that left is still registered")
|
||||
}
|
||||
|
||||
again := request(context.Background(), ic, scope, nil, tgt)
|
||||
eventually(t, "the next call to attach", func() bool { return waiters(ic, id) == 1 })
|
||||
if ids := openRows(t, d, scope); !slices.Equal(ids, []int64{id}) {
|
||||
t.Fatalf("open requests = %v, want the same row %d", ids, id)
|
||||
}
|
||||
if err := ic.Decide(id, true, "ARTEX", ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if res := result(t, again); res.Code != "" {
|
||||
t.Fatalf("attached call: %+v, want approved", res)
|
||||
}
|
||||
if got := pins(t, d, scope); len(got) != 1 {
|
||||
t.Fatalf("pins = %+v, want the late approval to pin", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWaitBoundReturnsPendingCode(t *testing.T) {
|
||||
d := targetTestDB(t)
|
||||
ic := New(d)
|
||||
scope := taskScope(t, d, ic, 150*time.Millisecond)
|
||||
tgt := target("app.example.com", 443, "203.0.113.7")
|
||||
start := time.Now()
|
||||
res := result(t, request(context.Background(), ic, scope, nil, tgt))
|
||||
if res.Code != TargetApprovalPending || res.PendingID == 0 {
|
||||
t.Fatalf("bounded call: %+v, want %s", res, TargetApprovalPending)
|
||||
}
|
||||
if waited := time.Since(start); waited < 150*time.Millisecond {
|
||||
t.Fatalf("the call returned after %v, before its wait bound", waited)
|
||||
}
|
||||
if ids := openRows(t, d, scope); !slices.Equal(ids, []int64{res.PendingID}) {
|
||||
t.Fatalf("open requests = %v, want the request %d left open", ids, res.PendingID)
|
||||
}
|
||||
|
||||
// Bound 0 (the final settlement round): never wait, but still ask.
|
||||
never := scope
|
||||
never.WaitBound = 0
|
||||
other := target("other.example.com", 443, "203.0.113.8")
|
||||
res = result(t, request(context.Background(), ic, never, nil, other))
|
||||
if res.Code != TargetApprovalPending || res.Host != "other.example.com" || res.PendingID == 0 {
|
||||
t.Fatalf("zero-bound call: %+v, want an immediate %s", res, TargetApprovalPending)
|
||||
}
|
||||
if len(openRows(t, d, scope)) != 2 {
|
||||
t.Fatalf("open requests = %v, want the zero-bound call to have created its request", openRows(t, d, scope))
|
||||
}
|
||||
// An ask is never merged into a row the call does not wait on.
|
||||
asked := ic.RequestTargets(context.Background(), never, "WebFetch", webFetchInput,
|
||||
[]TargetRequest{target("third.example.com", 443, "203.0.113.9")}, &Decision{Action: "ask", Message: "rule asks"})
|
||||
if asked.Code != TargetApprovalPending || asked.AskMerged {
|
||||
t.Fatalf("zero-bound call with an ask: %+v, want a plain open request", asked)
|
||||
}
|
||||
if row, err := d.GetInterceptPending(asked.PendingID); err != nil || row == nil || row.Status != "pending" {
|
||||
t.Fatalf("request of the zero-bound call = %+v, %v; want left open", row, err)
|
||||
}
|
||||
}
|
||||
|
||||
func askRule(t *testing.T, d *db.DB, timeoutEnabled bool, seconds int, action string) Decision {
|
||||
t.Helper()
|
||||
rule, err := d.CreateInterceptRule(fmt.Sprintf("target merge %d", time.Now().UnixNano()), "tool_name", "string",
|
||||
"NoSuchToolForTargetMerge", "ask", "", 0, false, timeoutEnabled, seconds, action)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = d.DeleteInterceptRule(rule.ID) })
|
||||
return Decision{Action: "ask", Message: "rule requires approval", RuleID: rule.ID, RuleName: rule.Name,
|
||||
TimeoutEnabled: timeoutEnabled, TimeoutSeconds: seconds, TimeoutAction: action}
|
||||
}
|
||||
|
||||
// A merged row is bound to the call that created it: when that call leaves, the
|
||||
// system closes the row, and nothing is pinned.
|
||||
func TestMergedRequestSettledWhenCreatorLeaves(t *testing.T) {
|
||||
d := targetTestDB(t)
|
||||
ic := New(d)
|
||||
scope := taskScope(t, d, ic, time.Minute)
|
||||
ask := askRule(t, d, false, 0, "deny")
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
call := request(ctx, ic, scope, &ask, target("app.example.com", 443, "203.0.113.7"))
|
||||
id := oneOpenRow(t, d, scope)
|
||||
detail, err := d.GetInterceptDetail(id)
|
||||
if err != nil || detail == nil || detail.RuleID == nil || *detail.RuleID != ask.RuleID || detail.DecisionSource != "target" ||
|
||||
detail.Audit == nil || !detail.Audit.MergedAsk || detail.Audit.RuleName != ask.RuleName || len(detail.Audit.Targets) != 1 {
|
||||
t.Fatalf("merged row = %+v, %v; want the rule and the target on one row", detail, err)
|
||||
}
|
||||
if !strings.Contains(detail.Reason, "rule requires approval") || !strings.Contains(detail.Reason, "app.example.com:443") ||
|
||||
!strings.Contains(detail.Reason, "203.0.113.7") || !strings.Contains(detail.Reason, "Approving pins these addresses for task "+scope.TaskID+" until revoked.") {
|
||||
t.Fatalf("merged reason does not name both the ask and the target: %q", detail.Reason)
|
||||
}
|
||||
eventually(t, "the creating call to wait", func() bool { return waiters(ic, id) == 1 })
|
||||
cancel()
|
||||
res := result(t, call)
|
||||
if res.Code != TargetDenied || !res.AskMerged || res.PendingID != id {
|
||||
t.Fatalf("creating call after cancel: %+v, want %s with the ask merged", res, TargetDenied)
|
||||
}
|
||||
row, err := d.GetInterceptDetail(id)
|
||||
if err != nil || row == nil || row.Status != "denied" || row.DecidedBy != "system" || row.Audit.EffectiveAction != "deny" ||
|
||||
row.Audit.DecisionReason != "work cancelled" {
|
||||
t.Fatalf("merged row after its creator left = %+v, %v; want denied by the system", row, err)
|
||||
}
|
||||
if got := pins(t, d, scope); len(got) != 0 {
|
||||
t.Fatalf("a row closed by the system pinned: %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// A timeout never pins, whatever the rule says: timeout_action = allow is
|
||||
// overridden on a merged row and the override is recorded.
|
||||
func TestTimeoutNeverPins(t *testing.T) {
|
||||
d := targetTestDB(t)
|
||||
ic := New(d)
|
||||
scope := taskScope(t, d, ic, time.Minute)
|
||||
ask := askRule(t, d, true, 1, "allow")
|
||||
res := result(t, request(context.Background(), ic, scope, &ask, target("app.example.com", 443, "203.0.113.7")))
|
||||
if res.Code != TargetDenied || !res.AskMerged {
|
||||
t.Fatalf("call after the rule timeout: %+v, want %s", res, TargetDenied)
|
||||
}
|
||||
row, err := d.GetInterceptDetail(res.PendingID)
|
||||
if err != nil || row == nil || row.Status != "timeout" || row.DecidedBy != "system" || row.Audit.EffectiveAction != "deny" ||
|
||||
row.Audit.ExecutionStatus != "not_executed" || !strings.Contains(row.Audit.DecisionReason, `timeout action "allow" was overridden`) {
|
||||
t.Fatalf("timed-out merged row = %+v, %v; want a timeout that denies and records the override", row, err)
|
||||
}
|
||||
if got := pins(t, d, scope); len(got) != 0 {
|
||||
t.Fatalf("a timeout pinned: %+v", got)
|
||||
}
|
||||
if len(openRows(t, d, scope)) != 0 {
|
||||
t.Fatal("the merged row is still open after its creator left")
|
||||
}
|
||||
}
|
||||
|
||||
// What is displayed is what is pinned: the addresses stored when the row was
|
||||
// created. A call that attaches with other addresses never rewrites them.
|
||||
func TestDecidePinsDisplayedIPs(t *testing.T) {
|
||||
d := targetTestDB(t)
|
||||
ic := New(d)
|
||||
scope := taskScope(t, d, ic, time.Minute)
|
||||
creator := request(context.Background(), ic, scope, nil, target("app.example.com", 443, "203.0.113.8", "203.0.113.7"))
|
||||
id := oneOpenRow(t, d, scope)
|
||||
attached := request(context.Background(), ic, scope, nil, target("app.example.com", 443, "198.51.100.66"))
|
||||
eventually(t, "both calls to wait", func() bool { return waiters(ic, id) == 2 })
|
||||
shown, err := d.GetInterceptDetail(id)
|
||||
if err != nil || shown == nil || len(shown.Audit.Targets) != 1 ||
|
||||
!slices.Equal(shown.Audit.Targets[0].ResolvedIPs, []string{"203.0.113.8", "203.0.113.7"}) || strings.Contains(shown.Reason, "198.51.100.66") {
|
||||
t.Fatalf("displayed request = %+v, %v; want the creator's addresses only", shown, err)
|
||||
}
|
||||
if err := ic.Decide(id, true, "ARTEX", "198.51.100.9"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if res := result(t, creator); res.Code != "" {
|
||||
t.Fatalf("creator: %+v", res)
|
||||
}
|
||||
if res := result(t, attached); res.Code != "" {
|
||||
t.Fatalf("attached: %+v", res)
|
||||
}
|
||||
got := pins(t, d, scope)
|
||||
if len(got) != 1 || !slices.Equal(got[0].PinnedIPs, []string{"203.0.113.7", "203.0.113.8"}) || got[0].InterceptID == nil || *got[0].InterceptID != id {
|
||||
t.Fatalf("pins = %+v, want exactly the displayed addresses", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeniedRequestPinsNothing(t *testing.T) {
|
||||
d := targetTestDB(t)
|
||||
ic := New(d)
|
||||
scope := taskScope(t, d, ic, time.Minute)
|
||||
tgt := target("app.example.com", 443, "203.0.113.7")
|
||||
call := request(context.Background(), ic, scope, nil, tgt)
|
||||
id := oneOpenRow(t, d, scope)
|
||||
if err := ic.Decide(id, false, "ARTEX", "198.51.100.9"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if res := result(t, call); res.Code != TargetDenied || res.PendingID != id {
|
||||
t.Fatalf("denied call: %+v, want %s", res, TargetDenied)
|
||||
}
|
||||
if got := pins(t, d, scope); len(got) != 0 {
|
||||
t.Fatalf("a denial pinned: %+v", got)
|
||||
}
|
||||
row, err := d.GetInterceptDetail(id)
|
||||
if err != nil || row == nil || row.Status != "denied" || row.DecidedBy != "human" || row.Audit.Targets[0].Outcome != "denied" ||
|
||||
row.Audit.Approver != "ARTEX" || row.Audit.ApproverAddr != "198.51.100.9" {
|
||||
t.Fatalf("denied row = %+v, %v", row, err)
|
||||
}
|
||||
// No sticky denial: the next call asks again, on a new row.
|
||||
next := request(context.Background(), ic, scope, nil, tgt)
|
||||
if fresh := oneOpenRow(t, d, scope); fresh == id {
|
||||
t.Fatal("the next call reused the denied row")
|
||||
} else if err := ic.Decide(fresh, true, "ARTEX", ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if res := result(t, next); res.Code != "" {
|
||||
t.Fatalf("call after the denial: %+v, want approved", res)
|
||||
}
|
||||
}
|
||||
|
||||
// A call attached to a merged row that closes without an approval is not
|
||||
// blocked for the host: the row was about another call. It asks once more with
|
||||
// a plain request, then blocks.
|
||||
func TestAttachedWaiterRequeuesAfterMergedDenial(t *testing.T) {
|
||||
d := targetTestDB(t)
|
||||
for _, approveRetry := range []bool{true, false} {
|
||||
t.Run(fmt.Sprintf("retry approved=%v", approveRetry), func(t *testing.T) {
|
||||
ic := New(d)
|
||||
scope := taskScope(t, d, ic, time.Minute)
|
||||
ask := askRule(t, d, false, 0, "deny")
|
||||
tgt := target("app.example.com", 443, "203.0.113.7")
|
||||
creator := request(context.Background(), ic, scope, &ask, tgt)
|
||||
merged := oneOpenRow(t, d, scope)
|
||||
attached := request(context.Background(), ic, scope, nil, tgt)
|
||||
eventually(t, "both calls to wait", func() bool { return waiters(ic, merged) == 2 })
|
||||
if err := ic.Decide(merged, false, "ARTEX", ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if res := result(t, creator); res.Code != TargetDenied || !res.AskMerged {
|
||||
t.Fatalf("creator of the merged row: %+v, want %s", res, TargetDenied)
|
||||
}
|
||||
var retry int64
|
||||
eventually(t, "the attached call to ask again", func() bool {
|
||||
ids := openRows(t, d, scope)
|
||||
if len(ids) == 1 && ids[0] != merged {
|
||||
retry = ids[0]
|
||||
}
|
||||
return retry != 0
|
||||
})
|
||||
plain, err := d.GetInterceptDetail(retry)
|
||||
if err != nil || plain == nil || plain.RuleID != nil || plain.Audit.MergedAsk {
|
||||
t.Fatalf("retry row = %+v, %v; want a plain target request", plain, err)
|
||||
}
|
||||
stillWaiting(t, attached)
|
||||
if err := ic.Decide(retry, approveRetry, "ARTEX", ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
res := result(t, attached)
|
||||
if approveRetry && (res.Code != "" || len(pins(t, d, scope)) != 1) {
|
||||
t.Fatalf("attached call after its retry was approved: %+v pins=%+v", res, pins(t, d, scope))
|
||||
}
|
||||
if !approveRetry && (res.Code != TargetDenied || res.PendingID != retry || len(pins(t, d, scope)) != 0) {
|
||||
t.Fatalf("attached call after its retry was denied: %+v, want %s", res, TargetDenied)
|
||||
}
|
||||
// One retry, then block: never a third request.
|
||||
if n := allRows(t, d, scope); n != 2 {
|
||||
t.Fatalf("%d request rows, want the merged one and one retry", n)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// The request rows of a conversation go with it (cascade). A waiter that finds
|
||||
// its row gone stops with a block.
|
||||
func TestWaiterStopsWhenRowDeleted(t *testing.T) {
|
||||
d := targetTestDB(t)
|
||||
ic := New(d)
|
||||
conv, err := d.CreateConversation("assistant", fmt.Sprintf("target wait %d", time.Now().UnixNano()), nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_ = d.DeleteConversation(conv.ID)
|
||||
_, _ = d.Exec(`DELETE FROM target_pins WHERE conversation_id=$1`, conv.ID)
|
||||
})
|
||||
scope := TargetScope{ConversationID: conv.ID, Enabled: func() bool { return true }, WaitBound: time.Minute, Interceptor: ic}
|
||||
call := request(WithConvID(context.Background(), conv.ID), ic, scope, nil, target("app.example.com", 443, "203.0.113.7"))
|
||||
id := oneOpenRow(t, d, scope)
|
||||
eventually(t, "the call to wait", func() bool { return waiters(ic, id) == 1 })
|
||||
// The card went to the conversation stream.
|
||||
var cards int
|
||||
if err := d.QueryRow(`SELECT count(*) FROM conversation_activities WHERE conversation_id=$1 AND kind='intercept_request'`, conv.ID).Scan(&cards); err != nil || cards != 1 {
|
||||
t.Fatalf("%d approval cards in the conversation (%v), want 1", cards, err)
|
||||
}
|
||||
if err := d.DeleteConversation(conv.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if res := result(t, call); res.Code != TargetGateUnavailable || res.PendingID != id {
|
||||
t.Fatalf("call whose request was deleted: %+v, want %s", res, TargetGateUnavailable)
|
||||
}
|
||||
}
|
||||
|
||||
// Every human decision records who took it and from where: on the pin and in
|
||||
// the audit of a target row, in the audit of a classic ask. decided_by on the
|
||||
// row stays the actor class.
|
||||
func TestDecideRecordsApproverAndAddress(t *testing.T) {
|
||||
d := targetTestDB(t)
|
||||
ic := New(d)
|
||||
scope := taskScope(t, d, ic, time.Minute)
|
||||
call := request(context.Background(), ic, scope, nil, target("app.example.com", 443, "203.0.113.7"))
|
||||
id := oneOpenRow(t, d, scope)
|
||||
if err := ic.Decide(id, true, "ARTEX", "198.51.100.9"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if res := result(t, call); res.Code != "" {
|
||||
t.Fatalf("approved call: %+v", res)
|
||||
}
|
||||
got := pins(t, d, scope)
|
||||
if len(got) != 1 || got[0].DecidedBy != "ARTEX" || got[0].DecidedFrom != "198.51.100.9" {
|
||||
t.Fatalf("pin = %+v, want the approver and the address", got)
|
||||
}
|
||||
row, err := d.GetInterceptDetail(id)
|
||||
if err != nil || row == nil || row.DecidedBy != "human" || row.Audit.Approver != "ARTEX" || row.Audit.ApproverAddr != "198.51.100.9" {
|
||||
t.Fatalf("target row = %+v, %v", row, err)
|
||||
}
|
||||
|
||||
// A classic ask goes through the same Decide and records the same.
|
||||
ctx := WithTaskContext(context.Background(), scope.TaskID, "work#1", nil)
|
||||
allowed := make(chan bool, 1)
|
||||
go func() {
|
||||
allowed <- ic.HandleAsk(ctx, 0, Decision{Action: "ask", Message: "classic ask"}, "Bash", []byte(`{"command":"id"}`))
|
||||
}()
|
||||
var classic int64
|
||||
eventually(t, "the classic ask", func() bool {
|
||||
return d.QueryRow(`SELECT id FROM intercept_pending WHERE task_id=$1 AND status='pending' AND target_key IS NULL`, scope.TaskID).Scan(&classic) == nil
|
||||
})
|
||||
eventually(t, "the classic ask to wait", func() bool { return waiters(ic, classic) == 1 })
|
||||
if err := ic.Decide(classic, true, "ARTEX", "198.51.100.10"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
select {
|
||||
case ok := <-allowed:
|
||||
if !ok {
|
||||
t.Fatal("the approved classic ask was refused")
|
||||
}
|
||||
case <-time.After(20 * time.Second):
|
||||
t.Fatal("the classic ask is still waiting")
|
||||
}
|
||||
row, err = d.GetInterceptDetail(classic)
|
||||
if err != nil || row == nil || row.Status != "allowed" || row.DecidedBy != "human" || row.Audit == nil ||
|
||||
row.Audit.Approver != "ARTEX" || row.Audit.ApproverAddr != "198.51.100.10" || len(row.Audit.Targets) != 0 {
|
||||
t.Fatalf("classic row = %+v, %v; want the approver and the address in its audit", row, err)
|
||||
}
|
||||
if len(pins(t, d, scope)) != 1 {
|
||||
t.Fatal("approving a classic ask pinned something")
|
||||
}
|
||||
}
|
||||
|
||||
// Behind the upstream proxy the proxy chooses the address: the request says the
|
||||
// approval is name-only and the pin carries the mark. The mark stays when the
|
||||
// proxy is removed; addresses are enforced again only after a new approval.
|
||||
func TestPinUnderGlobalProxyIsNameOnly(t *testing.T) {
|
||||
d := targetTestDB(t)
|
||||
ic := New(d)
|
||||
var proxied atomic.Bool
|
||||
ic.SetUpstreamProxyCheck(proxied.Load)
|
||||
scope := taskScope(t, d, ic, time.Minute)
|
||||
const sentence = "This approval is name-only."
|
||||
approve := func(host string) (*db.TargetPin, string) {
|
||||
t.Helper()
|
||||
call := request(context.Background(), ic, scope, nil, target(host, 443, "203.0.113.7"))
|
||||
id := oneOpenRow(t, d, scope)
|
||||
row, err := d.GetInterceptDetail(id)
|
||||
if err != nil || row == nil {
|
||||
t.Fatalf("request row: %+v, %v", row, err)
|
||||
}
|
||||
if err := ic.Decide(id, true, "ARTEX", ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if res := result(t, call); res.Code != "" {
|
||||
t.Fatalf("approved call: %+v", res)
|
||||
}
|
||||
active, err := d.ActiveTargetPins(scope.dbRef(t), host, 443)
|
||||
if err != nil || len(active) != 1 {
|
||||
t.Fatalf("active pins of %s = %+v, %v", host, active, err)
|
||||
}
|
||||
return active[0], row.Reason
|
||||
}
|
||||
|
||||
direct, reason := approve("direct.example.com")
|
||||
if direct.NameOnlyEffective || strings.Contains(reason, sentence) {
|
||||
t.Fatalf("without a proxy: pin=%+v reason=%q, want an address pin", direct, reason)
|
||||
}
|
||||
|
||||
proxied.Store(true)
|
||||
named, reason := approve("proxied.example.com")
|
||||
if !named.NameOnlyEffective || !strings.Contains(reason, sentence) || !strings.Contains(reason, "upstream proxy") {
|
||||
t.Fatalf("behind the proxy: pin=%+v reason=%q, want a name-only pin and a request that says so", named, reason)
|
||||
}
|
||||
if !slices.Equal(named.PinnedIPs, []string{"203.0.113.7"}) {
|
||||
t.Fatalf("name-only pin addresses = %v, want what the platform resolved", named.PinnedIPs)
|
||||
}
|
||||
|
||||
// The proxy is removed: the pin stays name-only, the address pin stays one.
|
||||
proxied.Store(false)
|
||||
ref := scope.dbRef(t)
|
||||
if still, err := d.ActiveTargetPins(ref, "proxied.example.com", 443); err != nil || len(still) != 1 || !still[0].NameOnlyEffective {
|
||||
t.Fatalf("name-only pin after the proxy was removed = %+v, %v; want still name-only", still, err)
|
||||
}
|
||||
if still, err := d.ActiveTargetPins(ref, "direct.example.com", 443); err != nil || len(still) != 1 || still[0].NameOnlyEffective {
|
||||
t.Fatalf("address pin = %+v, %v", still, err)
|
||||
}
|
||||
// Enforcing addresses again takes a revocation and a new approval.
|
||||
if _, err := d.RevokeTargetPin(ref, named.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if again, _ := approve("proxied.example.com"); again.NameOnlyEffective || again.Version != 2 {
|
||||
t.Fatalf("pin approved after the proxy was removed = %+v, want an address pin, version 2", again)
|
||||
}
|
||||
}
|
||||
|
||||
// A new_ip request shows the old version, the pinned addresses and the new
|
||||
// ones, and its approval extends the pin.
|
||||
func TestNewIPRequestShowsOldAndNewAddresses(t *testing.T) {
|
||||
d := targetTestDB(t)
|
||||
ic := New(d)
|
||||
scope := taskScope(t, d, ic, time.Minute)
|
||||
first := request(context.Background(), ic, scope, nil, target("app.example.com", 0, "203.0.113.7"))
|
||||
id := oneOpenRow(t, d, scope)
|
||||
if row, err := d.GetInterceptDetail(id); err != nil || row == nil || !strings.Contains(row.Reason, "app.example.com (port not declared: every port)") {
|
||||
t.Fatalf("port-0 request = %+v, %v; want the reason to say the port is not declared", row, err)
|
||||
}
|
||||
if err := ic.Decide(id, true, "ARTEX", ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if res := result(t, first); res.Code != "" {
|
||||
t.Fatalf("first call: %+v", res)
|
||||
}
|
||||
v1 := pins(t, d, scope)[0]
|
||||
|
||||
moved := target("app.example.com", 0, "203.0.113.99")
|
||||
moved.Reason, moved.Pin = "new_ip", v1
|
||||
second := request(context.Background(), ic, scope, nil, moved)
|
||||
id = oneOpenRow(t, d, scope)
|
||||
row, err := d.GetInterceptDetail(id)
|
||||
if err != nil || row == nil || !strings.Contains(row.Reason, "pin v1: 203.0.113.7") || !strings.Contains(row.Reason, "now resolves to 203.0.113.99") {
|
||||
t.Fatalf("new_ip request = %+v, %v; want the old version, the pinned and the new addresses", row, err)
|
||||
}
|
||||
if got := row.Audit.Targets[0]; got.Reason != "new_ip" || got.PinID != v1.ID || got.PinVersion != 1 || !slices.Equal(got.PinnedIPs, []string{"203.0.113.7"}) {
|
||||
t.Fatalf("recorded target = %+v", got)
|
||||
}
|
||||
if err := ic.Decide(id, true, "ARTEX", ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if res := result(t, second); res.Code != "" {
|
||||
t.Fatalf("second call: %+v", res)
|
||||
}
|
||||
active, err := d.ActiveTargetPins(scope.dbRef(t), "app.example.com", 0)
|
||||
if err != nil || len(active) != 1 || active[0].Version != 2 || !slices.Equal(active[0].PinnedIPs, []string{"203.0.113.7", "203.0.113.99"}) {
|
||||
t.Fatalf("pin after the new_ip approval = %+v, %v; want v2 with both addresses", active, err)
|
||||
}
|
||||
}
|
||||
|
||||
// One call, several targets: one request per host and port, and the call
|
||||
// passes only when all of them are approved.
|
||||
func TestSeveralTargetsNeedEveryApproval(t *testing.T) {
|
||||
d := targetTestDB(t)
|
||||
ic := New(d)
|
||||
scope := taskScope(t, d, ic, time.Minute)
|
||||
ask := askRule(t, d, false, 0, "deny")
|
||||
call := request(context.Background(), ic, scope, &ask,
|
||||
target("a.example.com", 443, "203.0.113.1"), target("b.example.com", 443, "203.0.113.2"))
|
||||
var ids []int64
|
||||
eventually(t, "two open requests", func() bool {
|
||||
ids = openRows(t, d, scope)
|
||||
return len(ids) == 2
|
||||
})
|
||||
// The ask is merged into the first row the call created; the other is plain.
|
||||
for n, id := range ids {
|
||||
row, err := d.GetInterceptDetail(id)
|
||||
if err != nil || row == nil || row.Audit.MergedAsk != (n == 0) || (row.RuleID != nil) != (n == 0) {
|
||||
t.Fatalf("row %d = %+v, %v; want the ask merged into the first row only", n, row, err)
|
||||
}
|
||||
}
|
||||
if err := ic.Decide(ids[0], true, "ARTEX", ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
stillWaiting(t, call)
|
||||
if err := ic.Decide(ids[1], false, "ARTEX", ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
res := result(t, call)
|
||||
if res.Code != TargetDenied || res.Host != "b.example.com" || !res.AskMerged {
|
||||
t.Fatalf("call with one target denied: %+v, want %s on b.example.com", res, TargetDenied)
|
||||
}
|
||||
if got := pins(t, d, scope); len(got) != 1 || got[0].Host != "a.example.com" {
|
||||
t.Fatalf("pins = %+v, want only the approved host", got)
|
||||
}
|
||||
}
|
||||
|
||||
// A scope that names no task and no conversation, or a task id that is not a
|
||||
// number, never reaches the store.
|
||||
func TestRequestTargetsRejectsBadScope(t *testing.T) {
|
||||
ic := New(nil)
|
||||
for name, scope := range map[string]TargetScope{
|
||||
"empty": {WaitBound: time.Minute},
|
||||
"both": {TaskID: "7", ConversationID: 9, WaitBound: time.Minute},
|
||||
"task not int": {TaskID: "seven", WaitBound: time.Minute},
|
||||
} {
|
||||
res := ic.RequestTargets(context.Background(), scope, "WebFetch", webFetchInput, []TargetRequest{target("app.example.com", 443, "203.0.113.7")}, nil)
|
||||
if res.Code != TargetGateUnavailable {
|
||||
t.Errorf("%s scope: %+v, want %s", name, res, TargetGateUnavailable)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,272 @@
|
||||
package intercept
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"slices"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestNormalizeTarget(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
raw string
|
||||
want Target
|
||||
}{
|
||||
{"case", "HTTPS://App.Example.COM/Admin", Target{TargetDomain, "app.example.com", 443}},
|
||||
{"trailing dot", "https://app.example.com./", Target{TargetDomain, "app.example.com", 443}},
|
||||
{"userinfo", "https://user:p@ss@app.example.com/x", Target{TargetDomain, "app.example.com", 443}},
|
||||
{"userinfo that looks like a host", "http://pinned.example@evil.example/", Target{TargetDomain, "evil.example", 80}},
|
||||
{"ipv6 brackets", "http://[2001:DB8::1]/", Target{TargetIP, "2001:db8::1", 80}},
|
||||
{"ipv6 brackets and port", "http://[2001:db8::1]:8443/x", Target{TargetIP, "2001:db8::1", 8443}},
|
||||
{"ipv4-mapped ipv6", "http://[::ffff:203.0.113.7]/", Target{TargetIP, "203.0.113.7", 80}},
|
||||
{"decimal ipv4", "http://2130706433/", Target{TargetIP, "127.0.0.1", 80}},
|
||||
{"hex ipv4", "http://0x7f000001/", Target{TargetIP, "127.0.0.1", 80}},
|
||||
{"octal ipv4", "http://0177.0.0.1/", Target{TargetIP, "127.0.0.1", 80}},
|
||||
{"short ipv4", "http://127.1/", Target{TargetIP, "127.0.0.1", 80}},
|
||||
{"mixed ipv4 forms", "http://0xa9.254.0xa9fe/", Target{TargetIP, "169.254.169.254", 80}},
|
||||
{"punycode", "https://bücher.example/", Target{TargetDomain, "xn--bcher-kva.example", 443}},
|
||||
{"punycode kept", "https://xn--bcher-kva.example/", Target{TargetDomain, "xn--bcher-kva.example", 443}},
|
||||
{"default port http", "http://app.example.com", Target{TargetDomain, "app.example.com", 80}},
|
||||
{"default port https", "https://app.example.com?x=1", Target{TargetDomain, "app.example.com", 443}},
|
||||
{"default port ws", "ws://app.example.com/socket", Target{TargetDomain, "app.example.com", 80}},
|
||||
{"default port wss", "wss://app.example.com/socket", Target{TargetDomain, "app.example.com", 443}},
|
||||
{"default port ftp", "ftp://files.example.com/pub", Target{TargetDomain, "files.example.com", 21}},
|
||||
{"explicit port wins", "https://app.example.com:8443/", Target{TargetDomain, "app.example.com", 8443}},
|
||||
{"empty port is the default", "https://app.example.com:/", Target{TargetDomain, "app.example.com", 443}},
|
||||
{"port 0: scheme without a default", "ssh://git.example.com/repo", Target{TargetDomain, "git.example.com", 0}},
|
||||
{"port 0: bare host", "app.example.com", Target{TargetDomain, "app.example.com", 0}},
|
||||
{"host and port", "App.Example.com:8080", Target{TargetDomain, "app.example.com", 8080}},
|
||||
{"ip and port", "203.0.113.7:22", Target{TargetIP, "203.0.113.7", 22}},
|
||||
{"bare ipv6", "2001:db8::1", Target{TargetIP, "2001:db8::1", 0}},
|
||||
{"ipv6 zone dropped", "fe80::1%eth0", Target{TargetIP, "fe80::1", 0}},
|
||||
{"cidr is masked", "10.1.2.3/8", Target{TargetCIDR, "10.0.0.0/8", 0}},
|
||||
{"ipv6 cidr", "FD00::/8", Target{TargetCIDR, "fd00::/8", 0}},
|
||||
{"underscore label", "http://my_host.internal/", Target{TargetDomain, "my_host.internal", 80}},
|
||||
{"percent-encoded host", "http://%65vil.example/", Target{TargetDomain, "evil.example", 80}},
|
||||
{"percent-encoded address", "http://127.0.0.1%2e/x", Target{TargetIP, "127.0.0.1", 80}},
|
||||
{"a host no profile accepts is kept", "https://-odd-.example/", Target{TargetDomain, "-odd-.example", 443}},
|
||||
}
|
||||
for _, c := range cases {
|
||||
got, ok := NormalizeTarget(c.raw)
|
||||
if !ok || got != c.want {
|
||||
t.Errorf("%s: NormalizeTarget(%q) = %+v, %v; want %+v", c.name, c.raw, got, ok, c.want)
|
||||
}
|
||||
}
|
||||
for _, raw := range []string{"", "file:///etc/passwd", "http:///path", "http://app.example.com:99999/", "http://app.example.com:http/", "http://[::1", "10.0.0.0/99"} {
|
||||
if got, ok := NormalizeTarget(raw); ok {
|
||||
t.Errorf("NormalizeTarget(%q) = %+v, want no target", raw, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func targetsOf(tool string, input any) []Target {
|
||||
raw, _ := json.Marshal(input)
|
||||
return ExtractTargets(tool, raw)
|
||||
}
|
||||
|
||||
func command(cmd string) map[string]string { return map[string]string{"command": cmd} }
|
||||
|
||||
func wantTargets(t *testing.T, what string, got []Target, want ...Target) {
|
||||
t.Helper()
|
||||
if !slices.Equal(got, want) {
|
||||
t.Errorf("%s: targets = %+v, want %+v", what, got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractTargets(t *testing.T) {
|
||||
app := Target{TargetDomain, "app.example.com", 443}
|
||||
|
||||
// WebFetch names its target exactly.
|
||||
wantTargets(t, "WebFetch", targetsOf("WebFetch", map[string]any{"url": "https://App.Example.com/login?next=http://other.example/", "extract": true}), app)
|
||||
wantTargets(t, "WebFetch numeric host", targetsOf("WebFetch", map[string]string{"url": "http://2130706433/"}), Target{TargetIP, "127.0.0.1", 80})
|
||||
wantTargets(t, "WebFetch without a url", targetsOf("WebFetch", map[string]string{}))
|
||||
|
||||
// The command tools: Bash, shell_open, Monitor (command) and shell_send (text).
|
||||
commands := []struct {
|
||||
cmd string
|
||||
want []Target
|
||||
}{
|
||||
{"curl -s https://app.example.com/api | jq .", []Target{app}},
|
||||
{"curl -sk 'https://app.example.com:8443/a?b=1&c=http://inner.example/' -o out", []Target{{TargetDomain, "app.example.com", 8443}}},
|
||||
{"curl http://a.example/;curl http://b.example|sh", []Target{{TargetDomain, "a.example", 80}, {TargetDomain, "b.example", 80}}},
|
||||
{"curl -x http://127.0.0.1:8080 https://app.example.com/", []Target{{TargetIP, "127.0.0.1", 8080}, app}},
|
||||
{"nmap -sV 203.0.113.7", []Target{{TargetIP, "203.0.113.7", 0}}},
|
||||
{"nmap -p 443 203.0.113.0/24 198.51.100.9", []Target{{TargetCIDR, "203.0.113.0/24", 0}, {TargetIP, "198.51.100.9", 0}}},
|
||||
{"nc -v 203.0.113.7:4444", []Target{{TargetIP, "203.0.113.7", 4444}}},
|
||||
{"ssh root@203.0.113.7 -p 22", []Target{{TargetIP, "203.0.113.7", 0}}},
|
||||
{"mysql --host=203.0.113.7 -uroot", []Target{{TargetIP, "203.0.113.7", 0}}},
|
||||
{"openssl s_client -connect app.example.com:443 -servername x", []Target{app}},
|
||||
{"nc localhost:8080", []Target{{TargetDomain, "localhost", 8080}}},
|
||||
{"ping6 2001:db8::1", []Target{{TargetIP, "2001:db8::1", 0}}},
|
||||
{"curl 'http://[2001:db8::1]:8080/'; nc [2001:db8::2]:22", []Target{{TargetIP, "2001:db8::1", 8080}, {TargetIP, "2001:db8::2", 22}}},
|
||||
{"nmap fd00::/8 ::ffff:203.0.113.7", []Target{{TargetCIDR, "fd00::/8", 0}, {TargetIP, "203.0.113.7", 0}}},
|
||||
{"curl https://app.example.com/a https://APP.example.com/b", []Target{app}},
|
||||
{"git clone git+ssh://git@code.example.com/x.git", []Target{{TargetDomain, "code.example.com", 0}}},
|
||||
{"scp f 203.0.113.7:/tmp/f", []Target{{TargetIP, "203.0.113.7", 0}}},
|
||||
{"docker run -p 203.0.113.7:8080:80 img", []Target{{TargetIP, "203.0.113.7", 8080}}},
|
||||
{"curl 203.0.113.7/2024/report", []Target{{TargetIP, "203.0.113.7", 0}}},
|
||||
{"dig @198.51.100.53 x", []Target{{TargetIP, "198.51.100.53", 0}}},
|
||||
// The authority of a URL reads differently quoted and unquoted: both readings are targets.
|
||||
{`curl 'http://pinned.example;@evil.example/'`, []Target{{TargetDomain, "evil.example", 80}, {TargetDomain, "pinned.example", 80}}},
|
||||
{`curl http://pinned.example\@evil.example/`, []Target{{TargetDomain, "evil.example", 80}, {TargetDomain, "pinned.example", 80}}},
|
||||
{`curl "http://pinned.example,x@evil.example:8443/"`, []Target{{TargetDomain, "evil.example", 8443}, {TargetDomain, "pinned.example", 80}}},
|
||||
{"curl http://a.example;curl http://b.example&&id", []Target{{TargetDomain, "a.example", 80}, {TargetDomain, "b.example", 80}}},
|
||||
{"curl http://user:pw@app.example.com:8443/ http://%65vil.example/", []Target{{TargetDomain, "app.example.com", 8443}, {TargetDomain, "evil.example", 80}}},
|
||||
// A port the gate cannot read leaves the address a target.
|
||||
{"for p in 80 443; do curl http://203.0.113.7:$p/; done", []Target{{TargetIP, "203.0.113.7", 0}}},
|
||||
}
|
||||
for _, c := range commands {
|
||||
wantTargets(t, "Bash "+c.cmd, targetsOf("Bash", command(c.cmd)), c.want...)
|
||||
wantTargets(t, "shell_open "+c.cmd, targetsOf("shell_open", command(c.cmd)), c.want...)
|
||||
wantTargets(t, "Monitor "+c.cmd, targetsOf("Monitor", command(c.cmd)), c.want...)
|
||||
wantTargets(t, "shell_send "+c.cmd, targetsOf("shell_send", map[string]string{"text": c.cmd + "\n"}), c.want...)
|
||||
}
|
||||
// Only the command field is read.
|
||||
wantTargets(t, "Bash description", targetsOf("Bash", map[string]string{"command": "ls", "description": "see https://app.example.com/"}))
|
||||
wantTargets(t, "shell_send session", targetsOf("shell_send", map[string]string{"text": "id\n", "session_id": "203.0.113.7"}))
|
||||
|
||||
// The field is read the way the tool decodes it: a key matches whatever its
|
||||
// case, and the last duplicate wins. The gate reads the value that runs.
|
||||
for _, c := range []struct{ tool, input string }{
|
||||
{"Bash", `{"command":"ls","Command":"curl https://app.example.com/"}`},
|
||||
{"Bash", `{"command":"ls","command":"curl https://app.example.com/"}`},
|
||||
{"Bash", `{"COMMAND":"curl https://app.example.com/"}`},
|
||||
{"Bash", `{"command":"curl https://app.example.com/","timeout":"not a number","text":5}`},
|
||||
{"Monitor", `{"command":"ls","cOmMaNd":"curl https://app.example.com/"}`},
|
||||
{"shell_send", `{"text":"id","Text":"curl https://app.example.com/\n"}`},
|
||||
{"WebFetch", `{"url":"https://pinned.example/","URL":"https://app.example.com/"}`},
|
||||
{"WebFetch", `{"url":"https://pinned.example/","url":"https://app.example.com/"}`},
|
||||
{"ExecuteExtraTool", `{"tool_name":"list_assets","Tool_Name":"mcp__x__y","params":{"u":"https://pinned.example/"},"PARAMS":{"u":"https://app.example.com/"}}`},
|
||||
} {
|
||||
wantTargets(t, c.tool+" "+c.input, ExtractTargets(c.tool, []byte(c.input)), app)
|
||||
}
|
||||
wantTargets(t, "first duplicate does not run", ExtractTargets("Bash", []byte(`{"command":"curl https://app.example.com/","Command":"ls"}`)))
|
||||
// A tool the gate does not know may keep either value of a duplicated key: both are read.
|
||||
wantTargets(t, "duplicate key, unknown tool", ExtractTargets("mcp__scan__run", []byte(`{"url":"https://app.example.com/","url":"https://other.example.com/"}`)),
|
||||
app, Target{TargetDomain, "other.example.com", 443})
|
||||
|
||||
// Things that look close to a target and are not one.
|
||||
for _, cmd := range []string{
|
||||
"sleep 300; echo 2130706433",
|
||||
"pip install requests==2.31.0 && python3 --version",
|
||||
"echo v1.2.3.4 > version; tar xf app-1.2.3.4.tgz",
|
||||
"date +%H:%M:%S; echo 12:30:45",
|
||||
"curl -u admin:1234 -H 'X-Id: 7' file:///etc/hosts",
|
||||
"docker run redis:7 && docker pull python:3.12",
|
||||
"chown user:1000 f; chmod 600 key.pem",
|
||||
"ip link show; echo 00:11:22:33:44:55",
|
||||
"echo 999.1.1.1 1.2.3 1.2.3.4.5",
|
||||
"python3 -c 'print(a[::-1])'; g++ -std=c++17 x.cc && echo std::cout",
|
||||
"cat ./src/main.go:42",
|
||||
} {
|
||||
wantTargets(t, "Bash "+cmd, targetsOf("Bash", command(cmd)))
|
||||
}
|
||||
}
|
||||
|
||||
// Every MCP tool and every deferred custom tool is called through
|
||||
// ExecuteExtraTool: the gate reads the inner tool name and its parameters.
|
||||
func TestExtractUnwrapsExecuteExtraTool(t *testing.T) {
|
||||
app := Target{TargetDomain, "app.example.com", 443}
|
||||
call := func(tool string, params any) []Target {
|
||||
return targetsOf("ExecuteExtraTool", map[string]any{"tool_name": tool, "params": params})
|
||||
}
|
||||
wantTargets(t, "mcp tool", call("mcp__browser__navigate", map[string]string{"url": "https://app.example.com/"}), app)
|
||||
wantTargets(t, "inner WebFetch", call("WebFetch", map[string]string{"url": "https://app.example.com/x"}), app)
|
||||
wantTargets(t, "inner Bash", call("Bash", map[string]string{"command": "nmap 203.0.113.7", "description": "https://app.example.com/"}),
|
||||
Target{TargetIP, "203.0.113.7", 0})
|
||||
wantTargets(t, "inner tool without a target", call("list_assets", map[string]string{"q": "https://app.example.com/"}))
|
||||
wantTargets(t, "no params", targetsOf("ExecuteExtraTool", map[string]any{"tool_name": "mcp__scan__run"}))
|
||||
// A call that does not name its inner tool is read as a whole.
|
||||
wantTargets(t, "no tool name", targetsOf("ExecuteExtraTool", map[string]any{"params": map[string]string{"url": "https://app.example.com/"}}), app)
|
||||
// The wrapper is unwrapped once: a nested wrapper is read as an unknown tool.
|
||||
wantTargets(t, "nested", call("ExecuteExtraTool", map[string]any{"tool_name": "list_assets", "params": map[string]string{"q": "https://app.example.com/"}}), app)
|
||||
}
|
||||
|
||||
func TestExtractSkipsNoTargetTools(t *testing.T) {
|
||||
input := map[string]any{
|
||||
"query": "site:app.example.com 203.0.113.7", "file_path": "/tmp/203.0.113.7.txt", "content": "curl https://app.example.com/",
|
||||
"url": "https://app.example.com/", "command": "nmap 203.0.113.7", "items": []any{map[string]any{"url": "https://app.example.com:8443/"}},
|
||||
}
|
||||
for _, tool := range []string{
|
||||
"web_search", "Read", "Write", "Edit", "MultiEdit", "LS", "Glob", "Grep", "TodoWrite", "sleep",
|
||||
"TaskOutput", "TaskStop", "TaskList",
|
||||
"add_intent", "insert_assets", "report_finding", "record_fact", "add_task_scope", "graph_overview",
|
||||
"spawn_task", "ask_operator", "traffic_search", "traffic_get", "traffic_blob",
|
||||
} {
|
||||
wantTargets(t, tool, targetsOf(tool, input))
|
||||
}
|
||||
}
|
||||
|
||||
// A tool the gate does not know is scanned, not skipped: a tool added later is
|
||||
// gated by default.
|
||||
func TestExtractUnknownToolScansStringLeaves(t *testing.T) {
|
||||
input := map[string]any{
|
||||
"target": "203.0.113.7",
|
||||
"options": map[string]any{"proxy": "http://127.0.0.1:8080", "ports": []any{"443", 8443}, "deep": true},
|
||||
"urls": []any{"https://app.example.com/a", map[string]any{"u": "https://app.example.com/b"}, "ftp://files.example.com"},
|
||||
"note": "x.py and README.md are files",
|
||||
}
|
||||
// In the order of the sorted keys: options, target, urls.
|
||||
want := []Target{
|
||||
{TargetIP, "127.0.0.1", 8080}, {TargetIP, "203.0.113.7", 0},
|
||||
{TargetDomain, "app.example.com", 443}, {TargetDomain, "files.example.com", 21},
|
||||
}
|
||||
for _, tool := range []string{"mcp__scan__run", "my_custom_http_tool", "SomeToolAddedLater", "create_mcp", "create_custom_tool"} {
|
||||
wantTargets(t, tool, targetsOf(tool, input), want...)
|
||||
}
|
||||
wantTargets(t, "input that is not JSON", ExtractTargets("mcp__scan__run", []byte("https://app.example.com/")))
|
||||
}
|
||||
|
||||
// A bare name is not a target in a command: a file name is a valid host name,
|
||||
// so reading bare names would ask on every script run.
|
||||
func TestExtractDoesNotTreatFileNamesAsHosts(t *testing.T) {
|
||||
for _, cmd := range []string{
|
||||
"python3 x.py", "node server.js --watch", "cat notes.txt README.md", "bash ./run.sh && go test ./...",
|
||||
"tar czf out.tar.gz data.json", "ls app.example.com.har", "vim config.yaml", "./exploit.py --out result.io",
|
||||
} {
|
||||
wantTargets(t, cmd, targetsOf("Bash", command(cmd)))
|
||||
}
|
||||
}
|
||||
|
||||
// The tests below document what the gate does NOT see. A subprocess path is
|
||||
// advisory: the gate detects a declared target and cannot prevent an undeclared
|
||||
// connection. Each asserts that no target is extracted, which is the gap. They
|
||||
// change only if that decision changes.
|
||||
|
||||
func TestBypass_BareHostnameNotExtracted(t *testing.T) {
|
||||
for _, cmd := range []string{"nmap app.example.com", "ping -c1 app.example.com", "curl app.example.com/login", "ssh root@app.example.com", "nc db 5432"} {
|
||||
wantTargets(t, cmd, targetsOf("Bash", command(cmd)))
|
||||
}
|
||||
}
|
||||
|
||||
func TestBypass_EncodedCommandHidesTarget(t *testing.T) {
|
||||
for _, cmd := range []string{
|
||||
"echo Y3VybCBodHRwczovL2FwcC5leGFtcGxlLmNvbS8= | base64 -d | sh",
|
||||
`python3 -c "import urllib.request as u; u.urlopen('https:/'+'/app.example'+'.com/')"`,
|
||||
"H=app.example; curl $H.com",
|
||||
"curl http://$TARGET/login https://${HOST}:8443/",
|
||||
"curl 'http://{app.example.com}/'",
|
||||
} {
|
||||
wantTargets(t, cmd, targetsOf("Bash", command(cmd)))
|
||||
}
|
||||
}
|
||||
|
||||
func TestBypass_ScriptFileHidesTarget(t *testing.T) {
|
||||
// The target is in the file, or in a list the tool reads.
|
||||
for _, cmd := range []string{"python3 exploit.py", "bash scan.sh", "nmap -iL targets.txt", "nuclei -l hosts.txt"} {
|
||||
wantTargets(t, cmd, targetsOf("Bash", command(cmd)))
|
||||
}
|
||||
}
|
||||
|
||||
func TestBypass_SplitKeystrokesHideTarget(t *testing.T) {
|
||||
// One address typed into an interactive shell in pieces: no call names it.
|
||||
for _, text := range []string{"nc 203.0.", "113.7 44", "44\n"} {
|
||||
wantTargets(t, text, targetsOf("shell_send", map[string]string{"text": text}))
|
||||
}
|
||||
}
|
||||
|
||||
func TestBypass_CustomScriptCodeNotScanned(t *testing.T) {
|
||||
// A custom script tool is called with its parameters only; the target is in
|
||||
// the stored code, which the hook never sees.
|
||||
wantTargets(t, "custom script tool", targetsOf("my_script_tool", map[string]any{"path": "/login", "retries": 3}))
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package intercept
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Autumn-27/artex/db"
|
||||
)
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
// Own database per package (#80), as in db, agent, server and evidence.
|
||||
db.SuffixTestDatabase("intercept")
|
||||
// A short poll keeps the cross-instance tests fast; production uses 2 s.
|
||||
targetPollInterval = 50 * time.Millisecond
|
||||
os.Exit(m.Run())
|
||||
}
|
||||
@@ -178,6 +178,28 @@ func verifyJWT(tokenStr string, key []byte) bool {
|
||||
return err == nil && t.Valid
|
||||
}
|
||||
|
||||
// jwtSubject returns the subject of tokenStr when it is a valid, non-expired
|
||||
// HS256 token, and "" otherwise.
|
||||
func jwtSubject(tokenStr string, key []byte) string {
|
||||
var claims jwt.RegisteredClaims
|
||||
t, err := jwt.ParseWithClaims(tokenStr, &claims, func(t *jwt.Token) (any, error) {
|
||||
if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
|
||||
return nil, fmt.Errorf("unexpected signing method")
|
||||
}
|
||||
return key, nil
|
||||
})
|
||||
if err != nil || !t.Valid {
|
||||
return ""
|
||||
}
|
||||
return claims.Subject
|
||||
}
|
||||
|
||||
// approver is who a human decision is recorded for: the subject of the
|
||||
// request's session token.
|
||||
func (s *Server) approver(r *http.Request) string {
|
||||
return jwtSubject(extractToken(r), s.jwtKey)
|
||||
}
|
||||
|
||||
// extractToken reads the JWT from Authorization: Bearer header,
|
||||
// artex_token cookie, or ?token= query param (for SSE connections).
|
||||
func extractToken(r *http.Request) string {
|
||||
|
||||
+13
-8
@@ -79,6 +79,7 @@ func (s *Server) pgCreateConversation(w http.ResponseWriter, r *http.Request) {
|
||||
AgentKey string `json:"agent_key"`
|
||||
Title string `json:"title"`
|
||||
LLMProfileID *int64 `json:"llm_profile_id"`
|
||||
TargetGate bool `json:"target_gate"` // approval gate for asset targets, opt-in
|
||||
}
|
||||
if !decodeConversationRequest(w, r, &req) {
|
||||
return
|
||||
@@ -115,7 +116,7 @@ func (s *Server) pgCreateConversation(w http.ResponseWriter, r *http.Request) {
|
||||
writeErr(w, 400, fmt.Sprintf("Title must be at most %d characters", maxConversationTitleRunes))
|
||||
return
|
||||
}
|
||||
c, err := pg.CreateConversation(req.AgentKey, title, req.LLMProfileID)
|
||||
c, err := pg.CreateConversationGated(req.AgentKey, title, req.LLMProfileID, req.TargetGate)
|
||||
if err != nil {
|
||||
writeErr(w, 500, err.Error())
|
||||
return
|
||||
@@ -176,14 +177,15 @@ func (s *Server) pgRenameConversation(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
Title *string `json:"title"`
|
||||
Pinned *bool `json:"pinned"`
|
||||
Title *string `json:"title"`
|
||||
Pinned *bool `json:"pinned"`
|
||||
TargetGate *bool `json:"target_gate"`
|
||||
}
|
||||
if !decodeConversationRequest(w, r, &req) {
|
||||
return
|
||||
}
|
||||
if req.Title == nil && req.Pinned == nil {
|
||||
writeErr(w, 400, "Provide at least title or pinned")
|
||||
if req.Title == nil && req.Pinned == nil && req.TargetGate == nil {
|
||||
writeErr(w, 400, "Provide at least title, pinned or target_gate")
|
||||
return
|
||||
}
|
||||
if req.Title != nil {
|
||||
@@ -198,7 +200,7 @@ func (s *Server) pgRenameConversation(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
req.Title = &title
|
||||
}
|
||||
updated, err := pg.UpdateConversation(c.ID, db.ConversationPatch{Title: req.Title, Pinned: req.Pinned})
|
||||
updated, err := pg.UpdateConversation(c.ID, db.ConversationPatch{Title: req.Title, Pinned: req.Pinned, TargetGate: req.TargetGate})
|
||||
if err != nil {
|
||||
writeErr(w, 500, err.Error())
|
||||
return
|
||||
@@ -559,8 +561,11 @@ func (s *Server) runConversationTurn(ctx context.Context, cancel context.CancelC
|
||||
}
|
||||
}
|
||||
// Approval scope of this turn's tool calls for the target gate (chat and retester).
|
||||
// Conversations have no opt-in flag yet, so Enabled stays nil: the gate is off.
|
||||
gctx := intercept.WithTargetScope(ctx, intercept.TargetScope{ConversationID: c.ID, WaitBound: intercept.DefaultTargetWaitBound})
|
||||
// The conversation's opt-in flag is the one read when the turn started: a
|
||||
// switch through the API applies from the next turn.
|
||||
gate := c.TargetGate
|
||||
gctx := intercept.WithTargetScope(ctx, intercept.TargetScope{ConversationID: c.ID, Enabled: func() bool { return gate },
|
||||
WaitBound: intercept.DefaultTargetWaitBound, Interceptor: s.m.interceptor})
|
||||
// On a manual stop ctx is cancelled; Chat already emits a clean "stopped by the user"
|
||||
// step, so skip the raw-error entry — only surface genuine failures.
|
||||
if _, err := ca.Chat(gctx, c.AgentKey, sessionID, msg, maxTurns, maxDuration, webSearch, emit); err != nil {
|
||||
|
||||
+19
-6
@@ -18,6 +18,8 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/Autumn-27/artex/db"
|
||||
"github.com/Autumn-27/artex/guard"
|
||||
"github.com/Autumn-27/artex/intercept"
|
||||
"github.com/Autumn-27/norma/permission"
|
||||
actool "github.com/Autumn-27/norma/tool"
|
||||
)
|
||||
@@ -163,11 +165,11 @@ func (s *Server) pgTestCustomTool(w http.ResponseWriter, r *http.Request) {
|
||||
var res actool.Result
|
||||
switch req.Kind {
|
||||
case "command":
|
||||
res, _ = s.runCommandTool(ctx, req.Exec, params, tc)
|
||||
res, _ = s.runCommandTool(ctx, "test", req.Exec, params, tc)
|
||||
case "script":
|
||||
res, _ = s.runScriptTool(ctx, "test", req.Exec, params, tc)
|
||||
case "http":
|
||||
res, _ = s.runHTTPTool(ctx, req.Exec, params, tc)
|
||||
res, _ = s.runHTTPTool(ctx, "test", req.Exec, params, tc)
|
||||
case "shell":
|
||||
writeErr(w, 400, "shell-kind tools are bash environment declarations with no executable content")
|
||||
return
|
||||
@@ -275,11 +277,11 @@ func (s *Server) buildCustomTool(t *db.Tool) actool.CoreTool {
|
||||
}
|
||||
switch kind {
|
||||
case "command":
|
||||
return s.runCommandTool(ctx, execRaw, params, tc)
|
||||
return s.runCommandTool(ctx, key, execRaw, params, tc)
|
||||
case "script":
|
||||
return s.runScriptTool(ctx, key, execRaw, params, tc)
|
||||
case "http":
|
||||
return s.runHTTPTool(ctx, execRaw, params, tc)
|
||||
return s.runHTTPTool(ctx, key, execRaw, params, tc)
|
||||
default:
|
||||
return actool.Errorf("unknown custom tool kind: " + kind), nil
|
||||
}
|
||||
@@ -328,13 +330,18 @@ func ensureSchema(raw json.RawMessage) map[string]any {
|
||||
|
||||
// ---------- command: render the command → reuse the Bash low-level run ----------
|
||||
|
||||
func (s *Server) runCommandTool(ctx context.Context, execRaw json.RawMessage, params map[string]any, tc *actool.ToolContext) (actool.Result, error) {
|
||||
func (s *Server) runCommandTool(ctx context.Context, key string, execRaw json.RawMessage, params map[string]any, tc *actool.ToolContext) (actool.Result, error) {
|
||||
var spec commandExec
|
||||
_ = json.Unmarshal(execRaw, &spec)
|
||||
if strings.TrimSpace(spec.Command) == "" {
|
||||
return actool.Errorf("command is empty"), nil
|
||||
}
|
||||
cmd := renderTemplate(spec.Command, params, shellQuote)
|
||||
// The hook saw the parameters, not the command they render: the target gate
|
||||
// reads the rendered command here, also without the quoting of the parameters.
|
||||
if res := intercept.GateRenderedCommand(ctx, key, cmd, renderTemplate(spec.Command, params, identity)); res.Code != "" {
|
||||
return actool.Errorf(guard.TargetBlockMessage(res)), nil
|
||||
}
|
||||
// Reuse the low-level run Bash also uses (via the Bash CoreTool.Call): it
|
||||
// automatically inherits the safety floor/timeout/proxy env/output overflow. The tool is a peer of Bash sharing that low level, not routed through the Bash tool for the model to call.
|
||||
bashIn, _ := json.Marshal(map[string]any{"command": cmd})
|
||||
@@ -417,7 +424,7 @@ func execPython(ctx context.Context, interp, key, code string, params map[string
|
||||
|
||||
// ---------- http: native request + proxy ----------
|
||||
|
||||
func (s *Server) runHTTPTool(ctx context.Context, execRaw json.RawMessage, params map[string]any, tc *actool.ToolContext) (actool.Result, error) {
|
||||
func (s *Server) runHTTPTool(ctx context.Context, key string, execRaw json.RawMessage, params map[string]any, tc *actool.ToolContext) (actool.Result, error) {
|
||||
var spec httpExec
|
||||
_ = json.Unmarshal(execRaw, &spec)
|
||||
method := strings.ToUpper(strings.TrimSpace(spec.Method))
|
||||
@@ -428,6 +435,12 @@ func (s *Server) runHTTPTool(ctx context.Context, execRaw json.RawMessage, param
|
||||
if strings.TrimSpace(rawURL) == "" {
|
||||
return actool.Errorf("http url is empty"), nil
|
||||
}
|
||||
// The hook saw the parameters, not the URL they render (nor a URL written in
|
||||
// the template): the target gate reads the rendered URL here, before the
|
||||
// tool's own timeout starts, so a wait for an approval does not consume it.
|
||||
if res := intercept.GateRenderedURL(ctx, key, rawURL); res.Code != "" {
|
||||
return actool.Errorf(guard.TargetBlockMessage(res)), nil
|
||||
}
|
||||
var bodyReader io.Reader
|
||||
if spec.Body != "" {
|
||||
bodyReader = strings.NewReader(renderTemplate(spec.Body, params, identity))
|
||||
|
||||
@@ -0,0 +1,662 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"iter"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/netip"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Autumn-27/artex/agent"
|
||||
"github.com/Autumn-27/artex/db"
|
||||
"github.com/Autumn-27/artex/intercept"
|
||||
"github.com/Autumn-27/norma/llm"
|
||||
actool "github.com/Autumn-27/norma/tool"
|
||||
)
|
||||
|
||||
// Step 1.8: the targets the custom tools render. The hook reads the parameters
|
||||
// of a custom tool, not the URL or the command they produce: the tool runs the
|
||||
// gate on what it is about to run. Plus the end-to-end run of a gated worker
|
||||
// and the run of a task with the gate off.
|
||||
|
||||
// countingServer is an HTTP server on 127.0.0.1 that counts the requests it
|
||||
// receives. Its host:port is an IP literal: the gate never resolves it.
|
||||
func countingServer(t *testing.T) (srv *httptest.Server, hits *atomic.Int64, host, port string) {
|
||||
t.Helper()
|
||||
hits = &atomic.Int64{}
|
||||
srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
hits.Add(1)
|
||||
w.Header().Set("Content-Type", "text/plain")
|
||||
_, _ = w.Write([]byte("probe-reached"))
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
u, err := url.Parse(srv.URL)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
host, port, err = net.SplitHostPort(u.Host)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return srv, hits, host, port
|
||||
}
|
||||
|
||||
// gatedCtx is the context of a worker tool call of task: its approval scope
|
||||
// with the given wait bound.
|
||||
func gatedCtx(task *Task, wait time.Duration) context.Context {
|
||||
ctx := intercept.WithTaskContext(context.Background(), task.ID, "worker", nil)
|
||||
return intercept.WithTargetScope(ctx, task.targetScope(wait))
|
||||
}
|
||||
|
||||
func execJSON(t *testing.T, v any) json.RawMessage {
|
||||
t.Helper()
|
||||
b, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
// callTool runs a custom tool the way the harness does and returns its text.
|
||||
func callTool(t *testing.T, tool actool.CoreTool, ctx context.Context, input string, tc *actool.ToolContext) (string, bool) {
|
||||
t.Helper()
|
||||
res, err := tool.Call(ctx, json.RawMessage(input), tc)
|
||||
if err != nil {
|
||||
t.Fatalf("%s: %v", tool.Name(), err)
|
||||
}
|
||||
return res.Flatten(), res.IsError
|
||||
}
|
||||
|
||||
// openTargetRequest returns the open target request of the task on key.
|
||||
func openTargetRequest(t *testing.T, pg *db.DB, taskID, key string) (id int64, toolName, toolInput string, ok bool) {
|
||||
t.Helper()
|
||||
err := pg.QueryRow(`SELECT id, tool_name, tool_input::text FROM intercept_pending
|
||||
WHERE task_id=$1 AND status='pending' AND target_key=$2`, taskID, key).Scan(&id, &toolName, &toolInput)
|
||||
if err != nil {
|
||||
return 0, "", "", false
|
||||
}
|
||||
return id, toolName, toolInput, true
|
||||
}
|
||||
|
||||
func targetRowCount(t *testing.T, pg *db.DB, taskID string) int {
|
||||
t.Helper()
|
||||
var n int
|
||||
if err := pg.QueryRow(`SELECT count(*) FROM intercept_pending WHERE task_id=$1 AND (decision_source='target' OR target_key IS NOT NULL)`, taskID).Scan(&n); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// A custom http tool is gated on the URL it renders, whether the target comes
|
||||
// from a parameter (here split over two parameters, so the hook reads no
|
||||
// target in them) or is written in the template. Nothing is sent before an
|
||||
// approval; an approval lets both through; a revoked pin asks again.
|
||||
func TestCustomHTTPRenderedTargetGated(t *testing.T) {
|
||||
g := newGateServer(t)
|
||||
on := true
|
||||
task := g.newTask(t, db.TaskCreateOptions{TargetGate: &on})
|
||||
pg := g.m.pg
|
||||
srv, hits, host, port := countingServer(t)
|
||||
key := net.JoinHostPort(host, port)
|
||||
dot := strings.LastIndexByte(host, '.')
|
||||
|
||||
rendered := g.s.buildCustomTool(&db.Tool{Key: "probe_http", Kind: "http",
|
||||
Schema: json.RawMessage(`{"type":"object","properties":{"last":{"type":"string"},"port":{"type":"string"}}}`),
|
||||
Exec: execJSON(t, map[string]any{"method": "GET", "url": "http://" + host[:dot+1] + "{last}:{port}/rendered"})})
|
||||
params := fmt.Sprintf(`{"last":%q,"port":%q}`, host[dot+1:], port)
|
||||
fixed := g.s.buildCustomTool(&db.Tool{Key: "probe_fixed", Kind: "http",
|
||||
Exec: execJSON(t, map[string]any{"method": "GET", "url": srv.URL + "/fixed"})})
|
||||
// The hook cannot see either target: neither input names one.
|
||||
for _, in := range []struct{ tool, input string }{{"probe_http", params}, {"probe_fixed", `{}`}} {
|
||||
if got := intercept.ExtractTargets(in.tool, []byte(in.input)); len(got) != 0 {
|
||||
t.Fatalf("%s: the hook reads %+v; the test needs a target only the rendering shows", in.tool, got)
|
||||
}
|
||||
}
|
||||
|
||||
// The template's own URL, wait bound 0: blocked at once, nothing sent, the
|
||||
// request open with the rendered URL as its input.
|
||||
out, isErr := callTool(t, fixed, gatedCtx(task, 0), `{}`, nil)
|
||||
if !isErr || !strings.Contains(out, "TARGET_APPROVAL_PENDING") || !strings.Contains(out, key) || hits.Load() != 0 {
|
||||
t.Fatalf("hard-coded URL: error=%v out=%q hits=%d", isErr, out, hits.Load())
|
||||
}
|
||||
id, toolName, toolInput, ok := openTargetRequest(t, pg, task.ID, key)
|
||||
if !ok || toolName != "probe_fixed" || !strings.Contains(toolInput, srv.URL+"/fixed") {
|
||||
t.Fatalf("request row: ok=%v tool=%q input=%s", ok, toolName, toolInput)
|
||||
}
|
||||
|
||||
// The rendered URL names the same host and port: the call attaches to the
|
||||
// open request and waits. One click lets it through.
|
||||
type result struct {
|
||||
out string
|
||||
isErr bool
|
||||
}
|
||||
done := make(chan result, 1)
|
||||
go func() {
|
||||
res, err := rendered.Call(gatedCtx(task, 30*time.Second), json.RawMessage(params), nil)
|
||||
if err != nil {
|
||||
done <- result{err.Error(), true}
|
||||
return
|
||||
}
|
||||
done <- result{res.Flatten(), res.IsError}
|
||||
}()
|
||||
time.Sleep(300 * time.Millisecond)
|
||||
if hits.Load() != 0 {
|
||||
t.Fatal("the rendered URL was fetched before the approval")
|
||||
}
|
||||
if n := targetRowCount(t, pg, task.ID); n != 1 {
|
||||
t.Fatalf("%d target rows, want the one request", n)
|
||||
}
|
||||
if code, body := g.do(t, "POST", fmt.Sprintf("/api/intercept/pending/%d/decide", id), `{"decision":"allowed"}`); code != 200 {
|
||||
t.Fatalf("decide: %d %v", code, body)
|
||||
}
|
||||
select {
|
||||
case r := <-done:
|
||||
if r.isErr || !strings.Contains(r.out, "probe-reached") || hits.Load() != 1 {
|
||||
t.Fatalf("after the approval: error=%v out=%q hits=%d", r.isErr, r.out, hits.Load())
|
||||
}
|
||||
case <-time.After(30 * time.Second):
|
||||
t.Fatal("the waiting call did not end after the approval")
|
||||
}
|
||||
if out, isErr := callTool(t, fixed, gatedCtx(task, 0), `{}`, nil); isErr || hits.Load() != 2 {
|
||||
t.Fatalf("pinned, hard-coded URL: error=%v out=%q hits=%d", isErr, out, hits.Load())
|
||||
}
|
||||
|
||||
// Revoked: the next call asks again and sends nothing.
|
||||
pins, err := pg.ListTargetPins(taskRef(t, task))
|
||||
if err != nil || len(pins) != 1 || pins[0].Host != host || fmt.Sprint(pins[0].Port) != port {
|
||||
t.Fatalf("pins = %+v, %v", pins, err)
|
||||
}
|
||||
if code, _ := g.do(t, "POST", fmt.Sprintf("/api/tasks/%s/target-pins/%d/revoke", task.ID, pins[0].ID), ""); code != 200 {
|
||||
t.Fatalf("revoke: %d", code)
|
||||
}
|
||||
out, isErr = callTool(t, rendered, gatedCtx(task, 0), params, nil)
|
||||
if !isErr || !strings.Contains(out, "TARGET_APPROVAL_PENDING") || hits.Load() != 2 {
|
||||
t.Fatalf("revoked: error=%v out=%q hits=%d", isErr, out, hits.Load())
|
||||
}
|
||||
if again, toolName, _, ok := openTargetRequest(t, pg, task.ID, key); !ok || again == id || toolName != "probe_http" {
|
||||
t.Fatalf("after the revoke: open request %d (ok=%v, tool %q), want a new one", again, ok, toolName)
|
||||
}
|
||||
|
||||
// A URL whose target cannot be read is blocked, never sent.
|
||||
broken := g.s.buildCustomTool(&db.Tool{Key: "probe_broken", Kind: "http",
|
||||
Schema: json.RawMessage(`{"type":"object","properties":{"host":{"type":"string"}}}`),
|
||||
Exec: execJSON(t, map[string]any{"url": "http://{host}/x"})})
|
||||
out, isErr = callTool(t, broken, gatedCtx(task, 0), `{"host":"a b"}`, nil)
|
||||
if !isErr || !strings.Contains(out, "TARGET_GATE_UNAVAILABLE") {
|
||||
t.Fatalf("unreadable URL: error=%v out=%q", isErr, out)
|
||||
}
|
||||
}
|
||||
|
||||
// A custom command tool is gated on the command it renders, with a target
|
||||
// written in the template or formed by a parameter inside a host name. The
|
||||
// command does not run before an approval.
|
||||
func TestCustomCommandRenderedTargetGated(t *testing.T) {
|
||||
g := newGateServer(t)
|
||||
on := true
|
||||
task := g.newTask(t, db.TaskCreateOptions{TargetGate: &on})
|
||||
pg := g.m.pg
|
||||
res := &countingResolver{answers: map[string][]string{"api.example.com": {"198.51.100.7"}}}
|
||||
g.m.interceptor.SetResolver(res)
|
||||
dir := t.TempDir()
|
||||
marker := filepath.Join(dir, "marker")
|
||||
ran := func() bool {
|
||||
_, err := os.Stat(marker)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
fixed := g.s.buildCustomTool(&db.Tool{Key: "probe_cmd_fixed", Kind: "command",
|
||||
Exec: execJSON(t, map[string]any{"command": "echo reached > '" + marker + "' && : http://198.51.100.7:8080/"})})
|
||||
glued := g.s.buildCustomTool(&db.Tool{Key: "probe_cmd", Kind: "command",
|
||||
Schema: json.RawMessage(`{"type":"object","properties":{"sub":{"type":"string"}}}`),
|
||||
Exec: execJSON(t, map[string]any{"command": "echo reached > '" + marker + "' && : http://{sub}.example.com:8080/"})})
|
||||
if got := intercept.ExtractTargets("probe_cmd", []byte(`{"sub":"api"}`)); len(got) != 0 {
|
||||
t.Fatalf("the hook reads %+v; the test needs a target only the rendering shows", got)
|
||||
}
|
||||
tc := &actool.ToolContext{WorkingDir: dir}
|
||||
|
||||
for _, c := range []struct {
|
||||
name string
|
||||
tool actool.CoreTool
|
||||
input string
|
||||
key string
|
||||
}{
|
||||
{"template", fixed, `{}`, "198.51.100.7:8080"},
|
||||
{"parameter inside the host", glued, `{"sub":"api"}`, "api.example.com:8080"},
|
||||
} {
|
||||
out, isErr := callTool(t, c.tool, gatedCtx(task, 0), c.input, tc)
|
||||
if !isErr || !strings.Contains(out, "TARGET_APPROVAL_PENDING") || ran() {
|
||||
t.Fatalf("%s: error=%v out=%q ran=%v, want a block before the command runs", c.name, isErr, out, ran())
|
||||
}
|
||||
id, toolName, _, ok := openTargetRequest(t, pg, task.ID, c.key)
|
||||
if !ok || toolName != c.tool.Name() {
|
||||
t.Fatalf("%s: no open request on %s for %s", c.name, c.key, c.tool.Name())
|
||||
}
|
||||
if code, body := g.do(t, "POST", fmt.Sprintf("/api/intercept/pending/%d/decide", id), `{"decision":"allowed"}`); code != 200 {
|
||||
t.Fatalf("%s: decide: %d %v", c.name, code, body)
|
||||
}
|
||||
if out, isErr := callTool(t, c.tool, gatedCtx(task, 0), c.input, tc); isErr || !ran() {
|
||||
t.Fatalf("%s, approved: error=%v out=%q ran=%v", c.name, isErr, out, ran())
|
||||
}
|
||||
_ = os.Remove(marker)
|
||||
}
|
||||
if res.lookups() == 0 {
|
||||
t.Fatal("the name formed by the parameter was never resolved")
|
||||
}
|
||||
}
|
||||
|
||||
// The custom-tool test endpoint is the operator's own action (D14): it has no
|
||||
// approval scope, so the gate is not on there, whatever the tasks say.
|
||||
func TestCustomToolTestEndpointUngated(t *testing.T) {
|
||||
g := newGateServer(t)
|
||||
on := true
|
||||
g.newTask(t, db.TaskCreateOptions{TargetGate: &on})
|
||||
_, hits, host, port := countingServer(t)
|
||||
before := 0
|
||||
if err := g.m.pg.QueryRow(`SELECT count(*) FROM intercept_pending WHERE decision_source='target' OR target_key IS NOT NULL`).Scan(&before); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
body := fmt.Sprintf(`{"kind":"http","exec":{"url":"http://%s:{port}/test"},"params":{"port":%q}}`, host, port)
|
||||
code, out := g.do(t, "POST", "/api/tools/custom/test", body)
|
||||
if code != 200 || out["is_error"] != false || !strings.Contains(fmt.Sprint(out["output"]), "probe-reached") || hits.Load() != 1 {
|
||||
t.Fatalf("http: %d %v hits=%d", code, out, hits.Load())
|
||||
}
|
||||
code, out = g.do(t, "POST", "/api/tools/custom/test", `{"kind":"command","exec":{"command":": http://198.51.100.7:8080/ && echo ran-ungated"}}`)
|
||||
if code != 200 || !strings.Contains(fmt.Sprint(out["output"]), "ran-ungated") {
|
||||
t.Fatalf("command: %d %v", code, out)
|
||||
}
|
||||
after := 0
|
||||
if err := g.m.pg.QueryRow(`SELECT count(*) FROM intercept_pending WHERE decision_source='target' OR target_key IS NOT NULL`).Scan(&after); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if after != before {
|
||||
t.Fatalf("the test endpoint wrote %d target rows", after-before)
|
||||
}
|
||||
}
|
||||
|
||||
// countingResolver answers from a table and counts every lookup.
|
||||
type countingResolver struct {
|
||||
mu sync.Mutex
|
||||
answers map[string][]string
|
||||
n int
|
||||
}
|
||||
|
||||
func (r *countingResolver) Name() string { return "counting" }
|
||||
|
||||
func (r *countingResolver) LookupIPs(_ context.Context, host string) ([]netip.Addr, error) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
r.n++
|
||||
var out []netip.Addr
|
||||
for _, ip := range r.answers[host] {
|
||||
out = append(out, netip.MustParseAddr(ip))
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return nil, fmt.Errorf("no answer for %s", host)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (r *countingResolver) lookups() int {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
return r.n
|
||||
}
|
||||
|
||||
// scriptedCall is one tool call of a toolScript.
|
||||
type scriptedCall struct{ tool, input string }
|
||||
|
||||
func fetches(urls ...string) []scriptedCall {
|
||||
calls := make([]scriptedCall, len(urls))
|
||||
for n, u := range urls {
|
||||
calls[n] = scriptedCall{"WebFetch", fmt.Sprintf(`{"url":%q}`, u)}
|
||||
}
|
||||
return calls
|
||||
}
|
||||
|
||||
// toolScript is a model that makes each of its calls in turn, one per turn,
|
||||
// then ends the run. It hands the result of every call to the test, and waits
|
||||
// before turn n while hold[n] is open.
|
||||
type toolScript struct {
|
||||
calls []scriptedCall
|
||||
hold map[int64]chan struct{}
|
||||
results chan string
|
||||
turn atomic.Int64
|
||||
}
|
||||
|
||||
func (p *toolScript) Stream(ctx context.Context, req llm.CompletionRequest) iter.Seq2[llm.StreamEvent, error] {
|
||||
n := p.turn.Add(1)
|
||||
if n > 1 {
|
||||
select {
|
||||
case p.results <- lastToolResult(req.Messages):
|
||||
default:
|
||||
}
|
||||
}
|
||||
if h := p.hold[n]; h != nil {
|
||||
select {
|
||||
case <-h:
|
||||
case <-ctx.Done():
|
||||
}
|
||||
}
|
||||
events := []llm.StreamEvent{{Type: llm.SETextDelta, Text: "done"}, {Type: llm.SEMessageDelta, StopReason: "end_turn"}}
|
||||
if int(n) <= len(p.calls) {
|
||||
call := p.calls[n-1]
|
||||
events = []llm.StreamEvent{
|
||||
{Type: llm.SEToolUseStart, ToolID: fmt.Sprintf("call-%d", n), ToolName: call.tool},
|
||||
{Type: llm.SEToolInputJSON, Text: call.input},
|
||||
{Type: llm.SEMessageDelta, StopReason: "tool_use"},
|
||||
}
|
||||
}
|
||||
return func(yield func(llm.StreamEvent, error) bool) {
|
||||
for _, event := range events {
|
||||
if !yield(event, nil) {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (p *toolScript) Complete(ctx context.Context, req llm.CompletionRequest) (llm.Message, string, llm.Usage, error) {
|
||||
return accumulateStreamForTest(ctx, p.Stream, req)
|
||||
}
|
||||
|
||||
func (p *toolScript) next(t *testing.T) string {
|
||||
t.Helper()
|
||||
select {
|
||||
case r := <-p.results:
|
||||
return r
|
||||
case <-time.After(60 * time.Second):
|
||||
t.Fatal("the worker did not hand back a tool result")
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
// lastToolResult is the text of the last tool result the model is shown.
|
||||
func lastToolResult(msgs []llm.Message) string {
|
||||
for i := len(msgs) - 1; i >= 0; i-- {
|
||||
for _, b := range msgs[i].Content {
|
||||
if b.Type != llm.BlockToolResult {
|
||||
continue
|
||||
}
|
||||
var s strings.Builder
|
||||
if b.IsError {
|
||||
s.WriteString("[error] ")
|
||||
}
|
||||
for _, c := range b.Content {
|
||||
s.WriteString(c.Text)
|
||||
}
|
||||
return s.String()
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// runScriptedWorker claims one intent of task and runs it with a worker driven
|
||||
// by p, in the background. extra are tools the worker has besides its own.
|
||||
func runScriptedWorker(t *testing.T, g *gateServer, task *Task, p llm.Provider, extra ...actool.CoreTool) <-chan struct{} {
|
||||
t.Helper()
|
||||
if _, err := task.Store.AddIntent(map[string]any{"summary": "fetch the probe"}, 1, nil, "planner"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
intent, token := g.s.engine.claimNext(task, "work#1")
|
||||
if intent == nil {
|
||||
t.Fatal("no intent claimed")
|
||||
}
|
||||
worker := agent.NewWorker(p, "test-model", g.m.dir, nil, 0, 8, extra...)
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
defer close(done)
|
||||
g.s.engine.runIntent(context.Background(), task, "work#1", worker, intent, token, "", "")
|
||||
}()
|
||||
t.Cleanup(func() {
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(60 * time.Second):
|
||||
t.Error("the worker run did not end")
|
||||
}
|
||||
})
|
||||
return done
|
||||
}
|
||||
|
||||
// A gated task end to end: a worker's WebFetch waits on a request, a click
|
||||
// through the API pins the address and the call is made, the next call passes
|
||||
// on the pin, the pin is listed and revoked, and the call after asks again.
|
||||
func TestTargetGateEndToEnd(t *testing.T) {
|
||||
g := newGateServer(t)
|
||||
on := true
|
||||
task := g.newTask(t, db.TaskCreateOptions{TargetGate: &on})
|
||||
pg := g.m.pg
|
||||
srv, hits, host, port := countingServer(t)
|
||||
key := net.JoinHostPort(host, port)
|
||||
target := srv.URL + "/e2e"
|
||||
p := &toolScript{calls: fetches(target, target, target), results: make(chan string, 16),
|
||||
hold: map[int64]chan struct{}{3: make(chan struct{})}}
|
||||
done := runScriptedWorker(t, g, task, p)
|
||||
|
||||
var id int64
|
||||
waitUntil(t, "the first request", func() bool {
|
||||
var ok bool
|
||||
id, _, _, ok = openTargetRequest(t, pg, task.ID, key)
|
||||
return ok
|
||||
})
|
||||
if hits.Load() != 0 {
|
||||
t.Fatal("fetched before the approval")
|
||||
}
|
||||
if code, body := g.do(t, "POST", fmt.Sprintf("/api/intercept/pending/%d/decide", id), `{"decision":"allowed"}`); code != 200 {
|
||||
t.Fatalf("decide: %d %v", code, body)
|
||||
}
|
||||
if r := p.next(t); !strings.Contains(r, "probe-reached") || hits.Load() != 1 {
|
||||
t.Fatalf("first call after the approval: %q hits=%d", r, hits.Load())
|
||||
}
|
||||
if r := p.next(t); !strings.Contains(r, "probe-reached") || hits.Load() != 2 {
|
||||
t.Fatalf("second call, pinned: %q hits=%d", r, hits.Load())
|
||||
}
|
||||
var requests int
|
||||
if err := pg.QueryRow(`SELECT count(*) FROM intercept_pending WHERE task_id=$1 AND target_key IS NOT NULL`, task.ID).Scan(&requests); err != nil || requests != 1 {
|
||||
t.Fatalf("%d requests, %v; want the one that was approved", requests, err)
|
||||
}
|
||||
|
||||
code, body := g.do(t, "GET", "/api/tasks/"+task.ID+"/target-pins", "")
|
||||
pins, _ := body["pins"].([]any)
|
||||
if code != 200 || len(pins) != 1 {
|
||||
t.Fatalf("pins: %d %v", code, body)
|
||||
}
|
||||
pin := pins[0].(map[string]any)
|
||||
if pin["host"] != host || fmt.Sprint(pin["port"]) != port || pin["status"] != "active" || fmt.Sprint(pin["pinned_ips"]) != "["+host+"]" {
|
||||
t.Fatalf("listed pin = %v", pin)
|
||||
}
|
||||
pinID, _ := pin["id"].(float64)
|
||||
if code, _ := g.do(t, "POST", fmt.Sprintf("/api/tasks/%s/target-pins/%d/revoke", task.ID, int64(pinID)), ""); code != 200 {
|
||||
t.Fatalf("revoke: %d", code)
|
||||
}
|
||||
close(p.hold[3])
|
||||
|
||||
var again int64
|
||||
waitUntil(t, "the request after the revoke", func() bool {
|
||||
var ok bool
|
||||
again, _, _, ok = openTargetRequest(t, pg, task.ID, key)
|
||||
return ok && again != id
|
||||
})
|
||||
if code, body := g.do(t, "POST", fmt.Sprintf("/api/intercept/pending/%d/decide", again), `{"decision":"denied"}`); code != 200 {
|
||||
t.Fatalf("deny: %d %v", code, body)
|
||||
}
|
||||
if r := p.next(t); !strings.Contains(r, "TARGET_DENIED") || hits.Load() != 2 {
|
||||
t.Fatalf("third call, revoked then denied: %q hits=%d", r, hits.Load())
|
||||
}
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(60 * time.Second):
|
||||
t.Fatal("the worker run did not end")
|
||||
}
|
||||
}
|
||||
|
||||
// A custom tool in a gated worker session. A deny rule on the call decides
|
||||
// first, at the hook: the tool never runs, so its rendered target is never
|
||||
// read, resolved or requested. The next call, which no rule refuses, is gated
|
||||
// inside the tool on the URL it renders.
|
||||
func TestDenyRuleWinsBeforeRenderedTarget(t *testing.T) {
|
||||
g := newGateServer(t)
|
||||
on := true
|
||||
task := g.newTask(t, db.TaskCreateOptions{TargetGate: &on})
|
||||
pg, ic := g.m.pg, g.m.interceptor
|
||||
_, hits, _, port := countingServer(t)
|
||||
res := &countingResolver{answers: map[string][]string{"api.example.com": {"127.0.0.1"}}}
|
||||
ic.SetResolver(res)
|
||||
|
||||
prior, err := ic.GetEnabledTools()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = ic.SetEnabledTools(prior) })
|
||||
if err := ic.SetEnabledTools(append(slices.Clone(prior), "probe_http")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
const sentinel = "ARTEX_RENDERED_DENY_PROBE"
|
||||
rule, err := pg.CreateInterceptRule("rendered deny probe", "tool_input", "string", sentinel, "deny", "", 1000000, true, false, 0, "deny")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_ = pg.DeleteInterceptRule(rule.ID)
|
||||
ic.Invalidate()
|
||||
})
|
||||
ic.Invalidate()
|
||||
|
||||
tool := g.s.buildCustomTool(&db.Tool{Key: "probe_http", Kind: "http",
|
||||
Schema: json.RawMessage(`{"type":"object","properties":{"sub":{"type":"string"},"note":{"type":"string"}}}`),
|
||||
Exec: execJSON(t, map[string]any{"url": "http://{sub}.example.com:" + port + "/deny"})})
|
||||
p := &toolScript{calls: []scriptedCall{
|
||||
{"probe_http", `{"sub":"api","note":"` + sentinel + `"}`},
|
||||
{"probe_http", `{"sub":"api"}`},
|
||||
}, results: make(chan string, 16), hold: map[int64]chan struct{}{2: make(chan struct{})}}
|
||||
done := runScriptedWorker(t, g, task, p, tool)
|
||||
|
||||
if r := p.next(t); !strings.Contains(r, "intercepted by the platform") || strings.Contains(r, "TARGET_") {
|
||||
t.Fatalf("call refused by the rule: %q", r)
|
||||
}
|
||||
if n, rows := res.lookups(), targetRowCount(t, pg, task.ID); n != 0 || rows != 0 || hits.Load() != 0 {
|
||||
t.Fatalf("after the deny: %d lookups, %d target rows, %d requests sent; want none", n, rows, hits.Load())
|
||||
}
|
||||
close(p.hold[2])
|
||||
|
||||
key := "api.example.com:" + port
|
||||
var id int64
|
||||
waitUntil(t, "the request for the rendered target", func() bool {
|
||||
var ok bool
|
||||
var tool string
|
||||
id, tool, _, ok = openTargetRequest(t, pg, task.ID, key)
|
||||
return ok && tool == "probe_http"
|
||||
})
|
||||
if code, body := g.do(t, "POST", fmt.Sprintf("/api/intercept/pending/%d/decide", id), `{"decision":"denied"}`); code != 200 {
|
||||
t.Fatalf("deny: %d %v", code, body)
|
||||
}
|
||||
if r := p.next(t); !strings.Contains(r, "TARGET_DENIED") || hits.Load() != 0 || res.lookups() == 0 {
|
||||
t.Fatalf("rendered target: %q, %d requests sent, %d lookups", r, hits.Load(), res.lookups())
|
||||
}
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(60 * time.Second):
|
||||
t.Fatal("the worker run did not end")
|
||||
}
|
||||
}
|
||||
|
||||
// Documents a known limit: script tools stay advisory (D9). A custom script
|
||||
// tool's target is in its stored code, which neither the hook nor the tool
|
||||
// reads, so in a gated task the call is allowed and the script reaches a target
|
||||
// nobody approved: no lookup, no request, no row. This test changes only if
|
||||
// that decision changes.
|
||||
func TestBypass_CustomScriptToolTargetNotGated(t *testing.T) {
|
||||
if detectPython() == "" {
|
||||
t.Skip("python3 unavailable")
|
||||
}
|
||||
g := newGateServer(t)
|
||||
on := true
|
||||
task := g.newTask(t, db.TaskCreateOptions{TargetGate: &on})
|
||||
res := &countingResolver{answers: map[string][]string{"localhost": {"127.0.0.1"}}}
|
||||
g.m.interceptor.SetResolver(res)
|
||||
_, hits, _, port := countingServer(t)
|
||||
|
||||
code := "import urllib.request\n" +
|
||||
"opener = urllib.request.build_opener(urllib.request.ProxyHandler({}))\n" +
|
||||
"print(opener.open('http://localhost:" + port + "/script', timeout=20).read().decode())\n"
|
||||
tool := g.s.buildCustomTool(&db.Tool{Key: "probe_script", Kind: "script", Exec: execJSON(t, map[string]any{"code": code})})
|
||||
// The gate sees no target in the call: only the parameters reach the hook.
|
||||
if got := intercept.ExtractTargets("probe_script", []byte(`{}`)); len(got) != 0 {
|
||||
t.Fatalf("the hook reads %+v; the test needs a target only the stored code names", got)
|
||||
}
|
||||
|
||||
p := &toolScript{calls: []scriptedCall{{"probe_script", `{}`}}, results: make(chan string, 16)}
|
||||
done := runScriptedWorker(t, g, task, p, tool)
|
||||
if r := p.next(t); !strings.Contains(r, "probe-reached") || strings.Contains(r, "TARGET_") {
|
||||
t.Fatalf("script tool in a gated task: %q, want it allowed and the target reached", r)
|
||||
}
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(60 * time.Second):
|
||||
t.Fatal("the worker run did not end")
|
||||
}
|
||||
if hits.Load() != 1 {
|
||||
t.Fatalf("%d requests reached the unapproved target, want 1 (the gap)", hits.Load())
|
||||
}
|
||||
if n, rows := res.lookups(), targetRowCount(t, g.m.pg, task.ID); n != 0 || rows != 0 {
|
||||
t.Fatalf("%d lookups, %d target rows; the gate did see the script's target", n, rows)
|
||||
}
|
||||
}
|
||||
|
||||
// A task with the gate off (the default) runs as before: a worker's WebFetch
|
||||
// and the custom tools reach their targets, and the gate writes no row and
|
||||
// resolves no name.
|
||||
func TestGateOffTaskIsUnchanged(t *testing.T) {
|
||||
g := newGateServer(t)
|
||||
task := g.newTask(t, db.TaskCreateOptions{})
|
||||
res := &countingResolver{answers: map[string][]string{"localhost": {"127.0.0.1"}}}
|
||||
g.m.interceptor.SetResolver(res)
|
||||
_, hits, _, port := countingServer(t)
|
||||
byName := "http://localhost:" + port
|
||||
|
||||
p := &toolScript{calls: fetches(byName+"/a", byName+"/b"), results: make(chan string, 16)}
|
||||
done := runScriptedWorker(t, g, task, p)
|
||||
for n := 1; n <= 2; n++ {
|
||||
if r := p.next(t); !strings.Contains(r, "probe-reached") {
|
||||
t.Fatalf("WebFetch %d: %q", n, r)
|
||||
}
|
||||
}
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(60 * time.Second):
|
||||
t.Fatal("the worker run did not end")
|
||||
}
|
||||
|
||||
httpTool := g.s.buildCustomTool(&db.Tool{Key: "probe_http", Kind: "http",
|
||||
Schema: json.RawMessage(`{"type":"object","properties":{"port":{"type":"string"}}}`),
|
||||
Exec: execJSON(t, map[string]any{"url": "http://localhost:{port}/c"})})
|
||||
if out, isErr := callTool(t, httpTool, gatedCtx(task, intercept.DefaultTargetWaitBound), fmt.Sprintf(`{"port":%q}`, port), nil); isErr || !strings.Contains(out, "probe-reached") {
|
||||
t.Fatalf("custom http tool: error=%v out=%q", isErr, out)
|
||||
}
|
||||
cmdTool := g.s.buildCustomTool(&db.Tool{Key: "probe_cmd", Kind: "command",
|
||||
Exec: execJSON(t, map[string]any{"command": ": http://localhost:" + port + "/ && echo ran-gate-off"})})
|
||||
if out, isErr := callTool(t, cmdTool, gatedCtx(task, intercept.DefaultTargetWaitBound), `{}`, &actool.ToolContext{WorkingDir: t.TempDir()}); isErr || !strings.Contains(out, "ran-gate-off") {
|
||||
t.Fatalf("custom command tool: error=%v out=%q", isErr, out)
|
||||
}
|
||||
|
||||
if hits.Load() != 3 {
|
||||
t.Fatalf("%d requests reached the server, want 3", hits.Load())
|
||||
}
|
||||
if n := targetRowCount(t, g.m.pg, task.ID); n != 0 {
|
||||
t.Fatalf("%d target rows, want none", n)
|
||||
}
|
||||
if n := res.lookups(); n != 0 {
|
||||
t.Fatalf("%d lookups, want none", n)
|
||||
}
|
||||
}
|
||||
@@ -128,7 +128,7 @@ func TestRunHTTPTool(t *testing.T) {
|
||||
"headers": map[string]string{"Authorization": "Bearer {token}"},
|
||||
"body": `{"flag":"{flag}"}`,
|
||||
})
|
||||
res, err := (&Server{}).runHTTPTool(context.Background(), execRaw,
|
||||
res, err := (&Server{}).runHTTPTool(context.Background(), "probe", execRaw,
|
||||
map[string]any{"flag": "CTF{x}", "token": "sekret"}, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("runHTTPTool: %v", err)
|
||||
@@ -176,7 +176,7 @@ func TestHTTPToolTruncatesLargeBody(t *testing.T) {
|
||||
defer srv.Close()
|
||||
|
||||
execRaw, _ := json.Marshal(map[string]any{"method": "GET", "url": srv.URL})
|
||||
res, err := (&Server{}).runHTTPTool(context.Background(), execRaw, map[string]any{}, nil)
|
||||
res, err := (&Server{}).runHTTPTool(context.Background(), "probe", execRaw, map[string]any{}, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("runHTTPTool: %v", err)
|
||||
}
|
||||
|
||||
@@ -63,6 +63,7 @@ type TaskDTO struct {
|
||||
CompanyIDs []int64 `json:"company_ids"`
|
||||
CoverageEnabled bool `json:"coverage_enabled"` // asset coverage feature switch (fixed at creation)
|
||||
PlanQuietMode bool `json:"plan_quiet_mode"` // planner quiet-mode switch (#86), editable mid-run
|
||||
TargetGate bool `json:"target_gate"` // approval gate for asset targets, opt-in, editable mid-run
|
||||
}
|
||||
|
||||
// FindingSeverityDTO is the per-severity finding count shown in the task list (critical/high/medium/low).
|
||||
@@ -138,6 +139,7 @@ func taskDTO(t *Task, status string) TaskDTO {
|
||||
CompanyIDs: lifecycle.CompanyIDs,
|
||||
CoverageEnabled: t.CoverageEnabled,
|
||||
PlanQuietMode: t.PlanQuietMode,
|
||||
TargetGate: t.targetGate.Load(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+143
-3
@@ -328,9 +328,9 @@ func interceptFilterParams(q url.Values) (db.InterceptApprovalFilter, error) {
|
||||
return filter, fmt.Errorf("status must be pending, allowed, denied or timeout")
|
||||
}
|
||||
switch filter.DecisionSource {
|
||||
case "", "model", "rule", "unknown":
|
||||
case "", "model", "rule", "target", "unknown":
|
||||
default:
|
||||
return filter, fmt.Errorf("decision_source must be model, rule or unknown")
|
||||
return filter, fmt.Errorf("decision_source must be model, rule, target or unknown")
|
||||
}
|
||||
switch filter.DecidedBy {
|
||||
case "", "human", "rule", "model", "system":
|
||||
@@ -372,17 +372,157 @@ func (s *Server) interceptDecide(w http.ResponseWriter, r *http.Request) {
|
||||
writeErr(w, 400, "decision must be allowed or denied")
|
||||
return
|
||||
}
|
||||
if err := s.m.interceptor.Decide(id, req.Decision == "allowed"); err != nil {
|
||||
// On a target request "allowed" pins the addresses the request displayed.
|
||||
// Every human decision records who took it and from where.
|
||||
if err := s.m.interceptor.Decide(id, req.Decision == "allowed", s.approver(r), clientIP(r, s.trustedProxies)); err != nil {
|
||||
if errors.Is(err, intercept.ErrAlreadyDecided) {
|
||||
writeErr(w, 409, err.Error())
|
||||
return
|
||||
}
|
||||
if errors.Is(err, db.ErrTargetPinEmpty) {
|
||||
// Refused, not failed: the request stays open and nothing was pinned.
|
||||
writeErr(w, 422, err.Error())
|
||||
return
|
||||
}
|
||||
writeErr(w, 500, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, 200, map[string]any{"ok": true})
|
||||
}
|
||||
|
||||
// --- target pins (hosts a human approved for one task or one conversation) ---
|
||||
|
||||
// taskPinScope resolves {id} to the pin scope of an existing task.
|
||||
func (s *Server) taskPinScope(w http.ResponseWriter, r *http.Request) (db.TargetScopeRef, bool) {
|
||||
id, ok := pathInt(r, "id")
|
||||
if !ok || id <= 0 {
|
||||
writeErr(w, 400, "bad task id")
|
||||
return db.TargetScopeRef{}, false
|
||||
}
|
||||
if _, ok := s.m.Task(strconv.FormatInt(id, 10)); !ok {
|
||||
writeErr(w, 404, "task not found")
|
||||
return db.TargetScopeRef{}, false
|
||||
}
|
||||
return db.TargetScopeRef{TaskID: id}, true
|
||||
}
|
||||
|
||||
func (s *Server) taskListTargetPins(w http.ResponseWriter, r *http.Request) {
|
||||
if scope, ok := s.taskPinScope(w, r); ok {
|
||||
s.listTargetPins(w, scope)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) taskRevokeTargetPin(w http.ResponseWriter, r *http.Request) {
|
||||
if scope, ok := s.taskPinScope(w, r); ok {
|
||||
s.revokeTargetPin(w, r, scope)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) convListTargetPins(w http.ResponseWriter, r *http.Request) {
|
||||
if _, c, ok := s.convByID(w, r); ok {
|
||||
s.listTargetPins(w, db.TargetScopeRef{ConversationID: c.ID})
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) convRevokeTargetPin(w http.ResponseWriter, r *http.Request) {
|
||||
if _, c, ok := s.convByID(w, r); ok {
|
||||
s.revokeTargetPin(w, r, db.TargetScopeRef{ConversationID: c.ID})
|
||||
}
|
||||
}
|
||||
|
||||
// listTargetPins returns every pin of the scope, in any state, newest first.
|
||||
func (s *Server) listTargetPins(w http.ResponseWriter, scope db.TargetScopeRef) {
|
||||
pg := s.pg(w)
|
||||
if pg == nil {
|
||||
return
|
||||
}
|
||||
pins, err := pg.ListTargetPins(scope)
|
||||
if err != nil {
|
||||
writeErr(w, 500, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, 200, map[string]any{"pins": pins})
|
||||
}
|
||||
|
||||
// revokeTargetPin revokes pin {pid} of the scope. The next call to that host
|
||||
// asks again. Revoking a pin that is no longer active changes nothing.
|
||||
func (s *Server) revokeTargetPin(w http.ResponseWriter, r *http.Request, scope db.TargetScopeRef) {
|
||||
pg := s.pg(w)
|
||||
if pg == nil {
|
||||
return
|
||||
}
|
||||
pid, ok := pathInt(r, "pid")
|
||||
if !ok {
|
||||
writeErr(w, 400, "bad pin id")
|
||||
return
|
||||
}
|
||||
pin, err := pg.RevokeTargetPin(scope, pid)
|
||||
if err != nil {
|
||||
writeErr(w, 500, err.Error())
|
||||
return
|
||||
}
|
||||
if pin == nil {
|
||||
writeErr(w, 404, "pin not found")
|
||||
return
|
||||
}
|
||||
writeJSON(w, 200, pin)
|
||||
}
|
||||
|
||||
// --- target-gate exemptions (hosts that need no target approval, global) ---
|
||||
|
||||
// targetGateGetExemptions returns the stored exemption list with its digest,
|
||||
// and the exemptions derived from the configuration (LLM gateways, search
|
||||
// backend, platform proxies), which are computed and cannot be edited.
|
||||
func (s *Server) targetGateGetExemptions(w http.ResponseWriter, r *http.Request) {
|
||||
list, digest, err := s.m.interceptor.Exemptions()
|
||||
if err != nil {
|
||||
writeErr(w, 500, err.Error())
|
||||
return
|
||||
}
|
||||
if list == nil {
|
||||
list = []db.TargetExemption{}
|
||||
}
|
||||
writeJSON(w, 200, map[string]any{"exemptions": list, "digest": digest, "derived": s.m.interceptor.DerivedExemptions()})
|
||||
}
|
||||
|
||||
// targetGateSetExemptions replaces the stored exemption list. Entries are an
|
||||
// exact host and port. The change is appended to the exemption log with the
|
||||
// approver of the session.
|
||||
func (s *Server) targetGateSetExemptions(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
Exemptions []db.TargetExemption `json:"exemptions"`
|
||||
}
|
||||
if err := decode(r, &req); err != nil {
|
||||
writeErr(w, 400, err.Error())
|
||||
return
|
||||
}
|
||||
list, digest, err := s.m.interceptor.SetExemptions(req.Exemptions, s.approver(r))
|
||||
if err != nil {
|
||||
if errors.Is(err, intercept.ErrInvalidExemption) {
|
||||
writeErr(w, 400, err.Error())
|
||||
return
|
||||
}
|
||||
writeErr(w, 500, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, 200, map[string]any{"exemptions": list, "digest": digest})
|
||||
}
|
||||
|
||||
// targetGateExemptionHistory returns the most recent changes of the exemption
|
||||
// list, newest first.
|
||||
func (s *Server) targetGateExemptionHistory(w http.ResponseWriter, r *http.Request) {
|
||||
pg := s.pg(w)
|
||||
if pg == nil {
|
||||
return
|
||||
}
|
||||
changes, err := pg.ListTargetExemptionLog(100)
|
||||
if err != nil {
|
||||
writeErr(w, 500, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, 200, map[string]any{"changes": changes})
|
||||
}
|
||||
|
||||
// --- tool-config (global tool interception scope) ---
|
||||
|
||||
// interceptGetToolConfig returns the list of tool names that are currently
|
||||
|
||||
+44
-4
@@ -13,6 +13,7 @@ import (
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/Autumn-27/artex/agent"
|
||||
@@ -72,6 +73,10 @@ type Task struct {
|
||||
notify chan struct{}
|
||||
lifecycleMu sync.RWMutex
|
||||
llmMu sync.RWMutex
|
||||
// targetGate is the task's opt-in to the approval gate for asset targets.
|
||||
// Atomic: it is switched through the API while tool calls read it.
|
||||
targetGate atomic.Bool
|
||||
interceptor *intercept.Interceptor // owner of the target gate, handed to the scope
|
||||
|
||||
// pendingTriggers accumulates the concrete changes (worker done / finding) that
|
||||
// fired planning rounds since the last one consumed them. The debounce coalesces
|
||||
@@ -397,6 +402,10 @@ func NewManager(dir, proxyAddr string) (*Manager, error) {
|
||||
log.Printf("[llmusage] create table: %v", err)
|
||||
}
|
||||
m := &Manager{dir: dir, pg: pg, assets: pg.Assets(), tasks: map[string]*Task{}, interceptor: intercept.New(pg)}
|
||||
// Behind the global egress proxy a target approval is name-only.
|
||||
m.interceptor.SetUpstreamProxyCheck(func() bool { return m.GlobalProxy() != "" })
|
||||
// The platform's own endpoints need no target approval.
|
||||
m.interceptor.SetDerivedExemptions(m.targetGateEndpoints)
|
||||
if proxyAddr != "" {
|
||||
tr, err := traffic.Open(filepath.Join(dir, "traffic"), proxyAddr)
|
||||
if err != nil {
|
||||
@@ -852,7 +861,7 @@ func unixNanoOrZero(t *time.Time) int64 {
|
||||
}
|
||||
|
||||
func taskFromPG(pt *pgdb.Task, store *pgdb.ExplorationStore, ic *intercept.Interceptor) *Task {
|
||||
return &Task{
|
||||
t := &Task{
|
||||
ID: strconv.FormatInt(pt.ID, 10), ExpID: pt.ExplorationID,
|
||||
Name: pt.Name,
|
||||
CategoryID: cloneInt64Ptr(pt.CategoryID), CategoryName: pt.CategoryName,
|
||||
@@ -871,14 +880,44 @@ func taskFromPG(pt *pgdb.Task, store *pgdb.ExplorationStore, ic *intercept.Inter
|
||||
CoverageEnabled: pt.CoverageEnabled,
|
||||
FirstRunAt: unixOrZero(pt.FirstRunAt), DeadlineAt: unixOrZero(pt.DeadlineAt),
|
||||
Store: store, Guard: guard.NewWithInterceptor(ic), TargetGuard: guard.NewTargetOnly(ic), notify: make(chan struct{}, 1),
|
||||
interceptor: ic,
|
||||
}
|
||||
t.targetGate.Store(pt.TargetGate)
|
||||
return t
|
||||
}
|
||||
|
||||
// targetScope is the approval scope of this task's tool calls for the target
|
||||
// gate. wait bounds how long a gated call may wait for a human decision. Tasks
|
||||
// have no opt-in flag yet, so Enabled stays nil: the gate is off.
|
||||
// gate. wait bounds how long a gated call may wait for a human decision.
|
||||
// Enabled reads the task's opt-in flag on every call, so a switch through the
|
||||
// API takes effect on the next tool call.
|
||||
func (t *Task) targetScope(wait time.Duration) intercept.TargetScope {
|
||||
return intercept.TargetScope{TaskID: t.ID, WaitBound: wait}
|
||||
return intercept.TargetScope{TaskID: t.ID, Enabled: t.targetGate.Load, WaitBound: wait, Interceptor: t.interceptor}
|
||||
}
|
||||
|
||||
// searchBackendEndpoints are the endpoints of the search backends. The
|
||||
// "deepseek" backend has none of its own: it uses the active LLM profile.
|
||||
var searchBackendEndpoints = map[string]string{
|
||||
"ddgs": "https://html.duckduckgo.com",
|
||||
"brave-free": "https://api.search.brave.com",
|
||||
"tavily": "https://api.tavily.com",
|
||||
}
|
||||
|
||||
// targetGateEndpoints lists the endpoints of the platform's own services, which
|
||||
// the target gate exempts from approval: every LLM gateway, the search backend
|
||||
// in use, and the proxies an agent may name on a command line (the recording
|
||||
// proxy, the global egress proxy, the search proxy and each profile's proxy).
|
||||
// It is read on every gated call, so the exemptions follow the configuration.
|
||||
func (m *Manager) targetGateEndpoints() []string {
|
||||
var out []string
|
||||
profiles, _ := m.pg.ListProfiles()
|
||||
for _, p := range profiles {
|
||||
out = append(out, p.BaseURL, p.Proxy)
|
||||
}
|
||||
on, backend, _, _, searchProxy := m.WebSearch()
|
||||
if on {
|
||||
out = append(out, searchBackendEndpoints[backend])
|
||||
}
|
||||
return append(out, searchProxy, m.ProxyAddr(), m.GlobalProxy())
|
||||
}
|
||||
|
||||
// UpdateTaskMetadata changes list-only task metadata without interrupting any
|
||||
@@ -907,6 +946,7 @@ func (m *Manager) UpdateTaskMetadata(taskID string, patch pgdb.TaskPatch) (*Task
|
||||
// Live switch (#86): runRound reads the flag per round, so the next
|
||||
// planning round (edge or heartbeat) already respects the new value.
|
||||
task.PlanQuietMode = updated.PlanQuietMode
|
||||
task.targetGate.Store(updated.TargetGate)
|
||||
return task, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -715,6 +715,8 @@ func (s *Server) Handler() http.Handler {
|
||||
mux.HandleFunc("PUT /api/tasks/{id}/intercept-rules/{rid}", s.taskInterceptUpdateRule)
|
||||
mux.HandleFunc("DELETE /api/tasks/{id}/intercept-rules/{rid}", s.taskInterceptDeleteRule)
|
||||
mux.HandleFunc("POST /api/tasks/{id}/intercept-rules/{rid}/toggle", s.taskInterceptToggleRule)
|
||||
mux.HandleFunc("GET /api/tasks/{id}/target-pins", s.taskListTargetPins)
|
||||
mux.HandleFunc("POST /api/tasks/{id}/target-pins/{pid}/revoke", s.taskRevokeTargetPin)
|
||||
mux.HandleFunc("POST /api/tasks/control/batch", s.controlTasksBatch)
|
||||
mux.HandleFunc("GET /api/task-archives", s.listTaskArchives)
|
||||
mux.HandleFunc("GET /api/task-archives/{id}", s.getTaskArchive)
|
||||
@@ -860,6 +862,8 @@ func (s *Server) Handler() http.Handler {
|
||||
mux.HandleFunc("POST /api/conversations/{id}/messages", s.pgSendConversationMessage)
|
||||
mux.HandleFunc("POST /api/conversations/{id}/stop", s.pgStopConversation)
|
||||
mux.HandleFunc("GET /api/conversations/{id}/messages/{seq}", s.pgConversationMsgDetail)
|
||||
mux.HandleFunc("GET /api/conversations/{id}/target-pins", s.convListTargetPins)
|
||||
mux.HandleFunc("POST /api/conversations/{id}/target-pins/{pid}/revoke", s.convRevokeTargetPin)
|
||||
|
||||
mux.HandleFunc("GET /api/agents", s.pgListAgents)
|
||||
mux.HandleFunc("POST /api/agents", s.pgCreateAgent)
|
||||
@@ -957,6 +961,9 @@ func (s *Server) Handler() http.Handler {
|
||||
mux.HandleFunc("GET /api/intercept/task/{taskID}", s.interceptListTaskItems)
|
||||
mux.HandleFunc("GET /api/intercept/tool-config", s.interceptGetToolConfig)
|
||||
mux.HandleFunc("PUT /api/intercept/tool-config", s.interceptSetToolConfig)
|
||||
mux.HandleFunc("GET /api/target-gate/exemptions", s.targetGateGetExemptions)
|
||||
mux.HandleFunc("PUT /api/target-gate/exemptions", s.targetGateSetExemptions)
|
||||
mux.HandleFunc("GET /api/target-gate/exemptions/history", s.targetGateExemptionHistory)
|
||||
mux.HandleFunc("GET /api/intercept/judge", s.interceptGetJudgeConfig)
|
||||
mux.HandleFunc("PUT /api/intercept/judge", s.interceptSetJudgeConfig)
|
||||
mux.HandleFunc("GET /api/intercept/judge/usage", s.interceptJudgeUsage) // cumulative token usage of fallback approval
|
||||
@@ -1584,6 +1591,7 @@ type createTaskReq struct {
|
||||
LLMRotate *bool `json:"llm_rotate"` // task-level timeout (seconds); 0/omitted = no limit
|
||||
PlanHeartbeatSeconds int `json:"plan_heartbeat_seconds"` // planner heartbeat interval (seconds); 0/omitted = default 600 (10min); floor = default = 600, lower values are raised to 600
|
||||
PlanQuietMode *bool `json:"plan_quiet_mode,omitempty"` // planner quiet-mode switch (#86); omitted = default on; false = edge rounds always plan
|
||||
TargetGate *bool `json:"target_gate,omitempty"` // approval gate for asset targets, opt-in; omitted/null = off
|
||||
SeedFirstIntent *bool `json:"seed_first_intent,omitempty"` // issue one seed intent at creation (content = description + goal) so a worker can start right away without waiting for the first planner round; omitted/null = default off, the standard plan-then-execute flow. Pass true explicitly to enable (for CTF tasks often solved in one work, this skips the pre-start planner round).
|
||||
CoverageEnabled *bool `json:"coverage_enabled,omitempty"` // asset coverage feature; omitted/null = default on (true). false = disables coverage calculation/display/auto-accumulated scope + hides add_task_scope/list_untested_assets. company links are unaffected.
|
||||
// InterceptRules: task-level asset interception/allow rules (entered at creation, stored in task_intercept_rules, never the global table).
|
||||
@@ -1660,6 +1668,7 @@ func (s *Server) createTask(w http.ResponseWriter, r *http.Request) {
|
||||
CoverageEnabled: req.CoverageEnabled,
|
||||
InterceptRules: interceptRules,
|
||||
LLMRotate: req.LLMRotate,
|
||||
TargetGate: req.TargetGate,
|
||||
})
|
||||
if err != nil {
|
||||
if errors.Is(err, db.ErrTaskCategoryInvalid) || errors.Is(err, db.ErrTaskCategoryNotFound) {
|
||||
|
||||
@@ -0,0 +1,553 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http/httptest"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Autumn-27/artex/agent"
|
||||
"github.com/Autumn-27/artex/db"
|
||||
"github.com/Autumn-27/artex/intercept"
|
||||
)
|
||||
|
||||
// Target-gate opt-in flag, pins and decisions over the real mux and the real
|
||||
// auth. The server context is already cancelled, so a task created through
|
||||
// these tests never starts an engine.
|
||||
|
||||
type gateServer struct {
|
||||
s *Server
|
||||
m *Manager
|
||||
token string
|
||||
}
|
||||
|
||||
func newGateServer(t *testing.T) *gateServer {
|
||||
t.Helper()
|
||||
skipWithoutDSN(t)
|
||||
m, err := NewManager(t.TempDir(), "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = m.Close() })
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
s := New(ctx, m, t.TempDir(), t.TempDir(), t.TempDir())
|
||||
token, err := signJWT(s.jwtKey)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return &gateServer{s: s, m: m, token: token}
|
||||
}
|
||||
|
||||
// do sends an authenticated request. httptest sets the peer to 192.0.2.1.
|
||||
func (g *gateServer) do(t *testing.T, method, path, body string) (int, map[string]any) {
|
||||
t.Helper()
|
||||
req := httptest.NewRequest(method, path, strings.NewReader(body))
|
||||
req.Header.Set("Authorization", "Bearer "+g.token)
|
||||
rec := httptest.NewRecorder()
|
||||
g.s.Handler().ServeHTTP(rec, req)
|
||||
var out map[string]any
|
||||
_ = json.Unmarshal(rec.Body.Bytes(), &out)
|
||||
return rec.Code, out
|
||||
}
|
||||
|
||||
// newTask creates a task through the manager and removes it, its pins and its
|
||||
// approval rows when the test ends.
|
||||
func (g *gateServer) newTask(t *testing.T, opts db.TaskCreateOptions) *Task {
|
||||
t.Helper()
|
||||
created, err := g.m.CreateTaskWithOptions("target gate flag", "flag, pins and decisions", opts)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
g.cleanupTask(t, created.ID)
|
||||
task, ok := g.m.Task(created.ID)
|
||||
if !ok {
|
||||
t.Fatal("task not found after creation")
|
||||
}
|
||||
return task
|
||||
}
|
||||
|
||||
func (g *gateServer) cleanupTask(t *testing.T, id string) {
|
||||
t.Helper()
|
||||
t.Cleanup(func() {
|
||||
n, _ := strconv.ParseInt(id, 10, 64)
|
||||
_ = g.m.pg.DeleteTask(n)
|
||||
_, _ = g.m.pg.Exec(`DELETE FROM target_pins WHERE task_id=$1`, n)
|
||||
_, _ = g.m.pg.Exec(`DELETE FROM intercept_pending WHERE task_id=$1`, id)
|
||||
})
|
||||
}
|
||||
|
||||
func taskRef(t *testing.T, task *Task) db.TargetScopeRef {
|
||||
t.Helper()
|
||||
id, err := strconv.ParseInt(task.ID, 10, 64)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return db.TargetScopeRef{TaskID: id}
|
||||
}
|
||||
|
||||
// openTargetRow opens a target approval request showing ips.
|
||||
func openTargetRow(t *testing.T, pg *db.DB, scope db.TargetScopeRef, host string, ips ...string) int64 {
|
||||
t.Helper()
|
||||
id, created, err := pg.FindOrCreateTargetRequest(db.TargetRequest{
|
||||
Scope: scope, Host: host, Port: 443, AgentName: "worker", ToolName: "WebFetch", Reason: "target " + host + " is not approved",
|
||||
Audit: &db.InterceptAudit{Correlation: "unavailable", InitialAction: "ask", ExecutionStatus: "not_started",
|
||||
Targets: []db.InterceptTarget{{Host: host, Port: 443, ResolvedIPs: ips, Resolver: "system", ResolvedAt: time.Now().UTC(), Outcome: "requested"}}},
|
||||
})
|
||||
if err != nil || !created {
|
||||
t.Fatalf("open target request: id=%d created=%v err=%v", id, created, err)
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
// The flag is read on every tool call: a switch through the API is seen by a
|
||||
// scope that was handed out before it.
|
||||
func TestTargetGateLiveSwitch(t *testing.T) {
|
||||
g := newGateServer(t)
|
||||
task := g.newTask(t, db.TaskCreateOptions{})
|
||||
scope := task.targetScope(intercept.DefaultTargetWaitBound)
|
||||
if scope.Enabled == nil || scope.Enabled() {
|
||||
t.Fatal("a new task must have the gate off")
|
||||
}
|
||||
if scope.Interceptor != g.m.interceptor {
|
||||
t.Fatal("the task scope does not carry the manager's interceptor")
|
||||
}
|
||||
if code, body := g.do(t, "GET", "/api/tasks/"+task.ID, ""); code != 200 || body["target_gate"] != false {
|
||||
t.Fatalf("task detail: %d %v, want target_gate false", code, body)
|
||||
}
|
||||
|
||||
code, body := g.do(t, "PATCH", "/api/tasks/"+task.ID, `{"target_gate":true}`)
|
||||
if code != 200 || body["target_gate"] != true {
|
||||
t.Fatalf("switch on: %d %v", code, body)
|
||||
}
|
||||
if !scope.Enabled() {
|
||||
t.Fatal("the scope handed out before the switch still reports the gate off")
|
||||
}
|
||||
id, _ := strconv.ParseInt(task.ID, 10, 64)
|
||||
if stored, err := g.m.pg.GetTask(id); err != nil || stored == nil || !stored.TargetGate {
|
||||
t.Fatalf("stored task = %+v, %v; want the gate on", stored, err)
|
||||
}
|
||||
// Another metadata patch leaves the flag alone.
|
||||
if code, body := g.do(t, "PATCH", "/api/tasks/"+task.ID, `{"name":"renamed"}`); code != 200 || body["target_gate"] != true || !scope.Enabled() {
|
||||
t.Fatalf("rename: %d %v, want the gate still on", code, body)
|
||||
}
|
||||
if code, body := g.do(t, "PATCH", "/api/tasks/"+task.ID, `{"target_gate":false}`); code != 200 || body["target_gate"] != false || scope.Enabled() {
|
||||
t.Fatalf("switch off: %d %v enabled=%v", code, body, scope.Enabled())
|
||||
}
|
||||
if code, body := g.do(t, "PATCH", "/api/tasks/"+task.ID, `{}`); code != 400 || !strings.Contains(fmt.Sprint(body["error"]), "target_gate") {
|
||||
t.Fatalf("empty patch: %d %v, want 400 naming target_gate", code, body)
|
||||
}
|
||||
}
|
||||
|
||||
const gateTestGoal = "Probe the lab target at http://127.0.0.1:9/v1 for unauthenticated data exposure; record one record as evidence and stop."
|
||||
|
||||
// The flag is set on the existing create path. Omitted means off, and a task
|
||||
// loaded from the database keeps what was stored.
|
||||
func TestCreateTaskAcceptsTargetGate(t *testing.T) {
|
||||
g := newGateServer(t)
|
||||
for _, tc := range []struct {
|
||||
field string
|
||||
want bool
|
||||
}{
|
||||
{`,"target_gate":true`, true},
|
||||
{`,"target_gate":false`, false},
|
||||
{``, false},
|
||||
} {
|
||||
code, body := g.do(t, "POST", "/api/tasks", `{"description":"gate create","goal":"`+gateTestGoal+`"`+tc.field+`}`)
|
||||
id, _ := body["id"].(string)
|
||||
if code != 201 || id == "" {
|
||||
t.Fatalf("create %q: %d %v", tc.field, code, body)
|
||||
}
|
||||
g.cleanupTask(t, id)
|
||||
task, ok := g.m.Task(id)
|
||||
if !ok || body["target_gate"] != tc.want || task.targetScope(0).Enabled() != tc.want {
|
||||
t.Fatalf("create %q: target_gate=%v, want %v", tc.field, body["target_gate"], tc.want)
|
||||
}
|
||||
n, _ := strconv.ParseInt(id, 10, 64)
|
||||
stored, err := g.m.pg.GetTask(n)
|
||||
if err != nil || stored == nil || stored.TargetGate != tc.want {
|
||||
t.Fatalf("create %q: stored %+v, %v; want %v", tc.field, stored, err, tc.want)
|
||||
}
|
||||
if loaded := taskFromPG(stored, g.m.pg.Exploration(stored.ExplorationID), g.m.interceptor); loaded.targetScope(0).Enabled() != tc.want {
|
||||
t.Fatalf("create %q: a task loaded from the database has the gate %v", tc.field, !tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// No inheritance: a task spawned by the orchestration agent starts with the
|
||||
// gate off, even from a gated parent.
|
||||
func TestSpawnedTaskStartsWithGateOff(t *testing.T) {
|
||||
g := newGateServer(t)
|
||||
on := true
|
||||
parent := g.newTask(t, db.TaskCreateOptions{TargetGate: &on})
|
||||
if !parent.targetScope(0).Enabled() {
|
||||
t.Fatal("control: the parent was expected to have the gate on")
|
||||
}
|
||||
in, _ := json.Marshal(map[string]any{"description": "spawned child", "goal": gateTestGoal, "parent_ref": parent.ID, "source_task_ids": []string{parent.ID}})
|
||||
res, err := g.s.toolSpawnTask().Call(context.Background(), in, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
childID, found := strings.CutPrefix(res.Flatten(), "task created: ")
|
||||
if !found {
|
||||
t.Fatalf("spawn_task result: %q", res.Flatten())
|
||||
}
|
||||
g.cleanupTask(t, childID)
|
||||
child, ok := g.m.Task(childID)
|
||||
if !ok || child.ParentRef != parent.ID {
|
||||
t.Fatalf("spawned task = %+v (found=%v), want a child of %s", child, ok, parent.ID)
|
||||
}
|
||||
if child.targetScope(0).Enabled() {
|
||||
t.Fatal("the spawned task inherited the gate")
|
||||
}
|
||||
n, _ := strconv.ParseInt(childID, 10, 64)
|
||||
if stored, err := g.m.pg.GetTask(n); err != nil || stored == nil || stored.TargetGate {
|
||||
t.Fatalf("stored child = %+v, %v; want the gate off", stored, err)
|
||||
}
|
||||
if pins, err := g.m.pg.ListTargetPins(db.TargetScopeRef{TaskID: n}); err != nil || len(pins) != 0 {
|
||||
t.Fatalf("the spawned task has pins: %+v, %v", pins, err)
|
||||
}
|
||||
}
|
||||
|
||||
// The conversation flag is set on create and on patch, and the turn that starts
|
||||
// after a switch runs with the new value.
|
||||
func TestConversationTargetGateOverHTTP(t *testing.T) {
|
||||
g := newGateServer(t)
|
||||
turnScope := func(id int64) intercept.TargetScope {
|
||||
t.Helper()
|
||||
conv, err := g.m.pg.GetConversation(id)
|
||||
if err != nil || conv == nil {
|
||||
t.Fatalf("conversation %d: %+v, %v", id, conv, err)
|
||||
}
|
||||
probe := newScopeProbeProvider()
|
||||
g.s.cfgMu.Lock()
|
||||
g.s.chatAgent = agent.NewChatAgent(probe, "test-model", g.m.dir, nil, 10000)
|
||||
g.s.cfgMu.Unlock()
|
||||
// The server context of this fixture is cancelled; the turn gets its own.
|
||||
ctx, cancel := context.WithCancelCause(intercept.WithConvID(context.Background(), id))
|
||||
g.s.runConversationTurn(ctx, cancel, conv, "hello", g.s.convBusyKey(id))
|
||||
got := probe.first(t)
|
||||
if !got.ok || got.scope.ConversationID != id || got.scope.Enabled == nil {
|
||||
t.Fatalf("turn scope = %+v (present=%v)", got.scope, got.ok)
|
||||
}
|
||||
return got.scope
|
||||
}
|
||||
create := func(body string) int64 {
|
||||
t.Helper()
|
||||
code, out := g.do(t, "POST", "/api/conversations", body)
|
||||
id, _ := out["id"].(float64)
|
||||
if code != 200 || id == 0 {
|
||||
t.Fatalf("create conversation: %d %v", code, out)
|
||||
}
|
||||
t.Cleanup(func() { _ = g.m.pg.DeleteConversation(int64(id)) })
|
||||
return int64(id)
|
||||
}
|
||||
|
||||
plain := create(`{"agent_key":"auto","title":"gate off"}`)
|
||||
if scope := turnScope(plain); scope.Enabled() {
|
||||
t.Fatal("a conversation created without the flag runs with the gate on")
|
||||
}
|
||||
gated := create(`{"agent_key":"auto","title":"gate on","target_gate":true}`)
|
||||
scope := turnScope(gated)
|
||||
if !scope.Enabled() || scope.Interceptor != g.m.interceptor {
|
||||
t.Fatalf("gated conversation: enabled=%v, want the gate on and the manager's interceptor", scope.Enabled())
|
||||
}
|
||||
path := "/api/conversations/" + strconv.FormatInt(gated, 10)
|
||||
if code, out := g.do(t, "PATCH", path, `{"target_gate":false}`); code != 200 || out["target_gate"] != false {
|
||||
t.Fatalf("switch off: %d %v", code, out)
|
||||
}
|
||||
if turnScope(gated).Enabled() {
|
||||
t.Fatal("the turn after the switch still runs with the gate on")
|
||||
}
|
||||
if code, out := g.do(t, "PATCH", path, `{"title":"renamed","target_gate":true}`); code != 200 || out["target_gate"] != true || out["title"] != "renamed" {
|
||||
t.Fatalf("switch on with a rename: %d %v", code, out)
|
||||
}
|
||||
if code, out := g.do(t, "PATCH", path, `{}`); code != 400 || !strings.Contains(fmt.Sprint(out["error"]), "target_gate") {
|
||||
t.Fatalf("empty patch: %d %v, want 400 naming target_gate", code, out)
|
||||
}
|
||||
}
|
||||
|
||||
// The decide endpoint keeps its request and response shape. On a target row
|
||||
// "allowed" pins the displayed addresses. Every human decision, on a target row
|
||||
// or on a classic ask, records the subject of the session token and the remote
|
||||
// address; decided_by stays the actor class.
|
||||
func TestInterceptDecideRecordsApproverOverHTTP(t *testing.T) {
|
||||
g := newGateServer(t)
|
||||
task := g.newTask(t, db.TaskCreateOptions{})
|
||||
scope := taskRef(t, task)
|
||||
pg := g.m.pg
|
||||
decide := func(id int64, decision string) int {
|
||||
t.Helper()
|
||||
code, _ := g.do(t, "POST", fmt.Sprintf("/api/intercept/pending/%d/decide", id), `{"decision":"`+decision+`"}`)
|
||||
return code
|
||||
}
|
||||
|
||||
target := openTargetRow(t, pg, scope, "app.example.com", "203.0.113.7")
|
||||
if code := decide(target, "allowed"); code != 200 {
|
||||
t.Fatalf("decide target row: %d", code)
|
||||
}
|
||||
pins, err := pg.ListTargetPins(scope)
|
||||
if err != nil || len(pins) != 1 || pins[0].DecidedBy != "ARTEX" || pins[0].DecidedFrom != "192.0.2.1" ||
|
||||
!slices.Equal(pins[0].PinnedIPs, []string{"203.0.113.7"}) || pins[0].InterceptID == nil || *pins[0].InterceptID != target {
|
||||
t.Fatalf("pins = %+v, %v; want one pin with the approver and the address", pins, err)
|
||||
}
|
||||
row, err := pg.GetInterceptDetail(target)
|
||||
if err != nil || row == nil || row.Status != "allowed" || row.DecidedBy != "human" || row.Audit.Approver != "ARTEX" || row.Audit.ApproverAddr != "192.0.2.1" {
|
||||
t.Fatalf("target row = %+v, %v", row, err)
|
||||
}
|
||||
if code := decide(target, "denied"); code != 409 {
|
||||
t.Fatalf("second decision: %d, want 409", code)
|
||||
}
|
||||
|
||||
denied := openTargetRow(t, pg, scope, "other.example.com", "203.0.113.8")
|
||||
if code := decide(denied, "denied"); code != 200 {
|
||||
t.Fatalf("deny target row: %d", code)
|
||||
}
|
||||
if pins, err := pg.ListTargetPins(scope); err != nil || len(pins) != 1 {
|
||||
t.Fatalf("pins after a denial = %+v, %v; want still one", pins, err)
|
||||
}
|
||||
|
||||
classic, err := pg.CreateInterceptPending(0, 0, task.ID, "worker", "Bash", []byte(`{"command":"id"}`), "classic ask",
|
||||
&db.InterceptAudit{Correlation: "exact", InitialAction: "ask", ExecutionStatus: "not_started"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if code := decide(classic, "allowed"); code != 200 {
|
||||
t.Fatalf("decide classic ask: %d", code)
|
||||
}
|
||||
row, err = pg.GetInterceptDetail(classic)
|
||||
if err != nil || row == nil || row.Status != "allowed" || row.DecidedBy != "human" || row.Audit == nil ||
|
||||
row.Audit.Approver != "ARTEX" || row.Audit.ApproverAddr != "192.0.2.1" || row.Audit.EffectiveAction != "allow" {
|
||||
t.Fatalf("classic row = %+v, %v; want the approver and the address in its audit", row, err)
|
||||
}
|
||||
if pins, err := pg.ListTargetPins(scope); err != nil || len(pins) != 1 {
|
||||
t.Fatalf("pins after a classic approval = %+v, %v; want still one", pins, err)
|
||||
}
|
||||
|
||||
// Target rows are listed under their own decision source.
|
||||
code, list := g.do(t, "GET", "/api/intercept/task/"+task.ID+"?decision_source=target&page=1&size=50", "")
|
||||
if items, _ := list["items"].([]any); code != 200 || len(items) != 2 {
|
||||
t.Fatalf("decision_source=target: %d %v, want the two target rows", code, list)
|
||||
}
|
||||
if code, _ := g.do(t, "GET", "/api/intercept/history?decision_source=nonsense&page=1", ""); code != 400 {
|
||||
t.Fatalf("unknown decision_source: %d, want 400", code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestJWTSubject(t *testing.T) {
|
||||
key := []byte("0123456789abcdef0123456789abcdef")
|
||||
token, err := signJWT(key)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := jwtSubject(token, key); got != "ARTEX" {
|
||||
t.Fatalf("subject = %q, want ARTEX", got)
|
||||
}
|
||||
if got := jwtSubject(token, []byte("another key, another key, another")); got != "" {
|
||||
t.Fatalf("subject of a token signed with another key = %q, want none", got)
|
||||
}
|
||||
if got := jwtSubject("", key); got != "" {
|
||||
t.Fatalf("subject of no token = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// Pin routes: list and revoke, scoped to one existing task or conversation.
|
||||
func TestTargetPinRoutes(t *testing.T) {
|
||||
g := newGateServer(t)
|
||||
pg := g.m.pg
|
||||
task, other := g.newTask(t, db.TaskCreateOptions{}), g.newTask(t, db.TaskCreateOptions{})
|
||||
conv, err := pg.CreateConversation("auto", "pin routes", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_ = pg.DeleteConversation(conv.ID)
|
||||
_, _ = pg.Exec(`DELETE FROM target_pins WHERE conversation_id=$1`, conv.ID)
|
||||
})
|
||||
pin := func(scope db.TargetScopeRef, host string) int64 {
|
||||
t.Helper()
|
||||
p, ok, err := pg.ResolveTargetRequest(openTargetRow(t, pg, scope, host, "203.0.113.7"), true, "ARTEX", "192.0.2.1")
|
||||
if err != nil || !ok || p == nil {
|
||||
t.Fatalf("pin %s: %+v ok=%v err=%v", host, p, ok, err)
|
||||
}
|
||||
return p.ID
|
||||
}
|
||||
taskPin := pin(taskRef(t, task), "app.example.com")
|
||||
otherPin := pin(taskRef(t, other), "app.example.com")
|
||||
convPin := pin(db.TargetScopeRef{ConversationID: conv.ID}, "chat.example.com")
|
||||
|
||||
listed := func(path string) []any {
|
||||
t.Helper()
|
||||
code, body := g.do(t, "GET", path, "")
|
||||
pins, ok := body["pins"].([]any)
|
||||
if code != 200 || !ok {
|
||||
t.Fatalf("GET %s: %d %v", path, code, body)
|
||||
}
|
||||
return pins
|
||||
}
|
||||
for _, tc := range []struct {
|
||||
base string
|
||||
id int64
|
||||
host string
|
||||
}{
|
||||
{"/api/tasks/" + task.ID, taskPin, "app.example.com"},
|
||||
{"/api/conversations/" + strconv.FormatInt(conv.ID, 10), convPin, "chat.example.com"},
|
||||
} {
|
||||
pins := listed(tc.base + "/target-pins")
|
||||
if len(pins) != 1 {
|
||||
t.Fatalf("%s: %d pins, want 1", tc.base, len(pins))
|
||||
}
|
||||
got := pins[0].(map[string]any)
|
||||
if got["id"] != float64(tc.id) || got["host"] != tc.host || got["status"] != "active" || got["decided_by"] != "ARTEX" || got["name_only_effective"] != false {
|
||||
t.Fatalf("%s: listed pin = %v", tc.base, got)
|
||||
}
|
||||
revoke := fmt.Sprintf("%s/target-pins/%d/revoke", tc.base, tc.id)
|
||||
code, body := g.do(t, "POST", revoke, "")
|
||||
if code != 200 || body["status"] != "revoked" || body["revoked_reason"] != "operator" {
|
||||
t.Fatalf("POST %s: %d %v", revoke, code, body)
|
||||
}
|
||||
at := body["revoked_at"]
|
||||
// Revoking again changes nothing.
|
||||
if code, body := g.do(t, "POST", revoke, ""); code != 200 || body["status"] != "revoked" || body["revoked_at"] != at {
|
||||
t.Fatalf("second POST %s: %d %v", revoke, code, body)
|
||||
}
|
||||
if pins := listed(tc.base + "/target-pins"); len(pins) != 1 || pins[0].(map[string]any)["status"] != "revoked" {
|
||||
t.Fatalf("%s after the revoke: %v, want the pin kept as revoked", tc.base, pins)
|
||||
}
|
||||
// A pin of another scope is not reachable through this one.
|
||||
if code, _ := g.do(t, "POST", fmt.Sprintf("%s/target-pins/%d/revoke", tc.base, otherPin), ""); code != 404 {
|
||||
t.Fatalf("revoke of a foreign pin through %s: %d, want 404", tc.base, code)
|
||||
}
|
||||
}
|
||||
if active, err := pg.ActiveTargetPins(taskRef(t, other), "app.example.com", 443); err != nil || len(active) != 1 {
|
||||
t.Fatalf("the pin of the other task = %+v, %v; want still active", active, err)
|
||||
}
|
||||
for _, path := range []string{"/api/tasks/999999999/target-pins", "/api/conversations/999999999/target-pins"} {
|
||||
if code, _ := g.do(t, "GET", path, ""); code != 404 {
|
||||
t.Fatalf("GET %s: %d, want 404", path, code)
|
||||
}
|
||||
}
|
||||
if code, _ := g.do(t, "POST", "/api/tasks/"+task.ID+"/target-pins/notanumber/revoke", ""); code != 400 {
|
||||
t.Fatalf("revoke with a bad pin id: %d, want 400", code)
|
||||
}
|
||||
}
|
||||
|
||||
// A worker that loses its claim while it waits in the gate stops waiting, and
|
||||
// the request stays open: it is host-level, not owned by the call. The worker
|
||||
// that took the intent over attaches to the same request.
|
||||
func TestClaimLostEndsGateWait(t *testing.T) {
|
||||
g := newGateServer(t)
|
||||
task := g.newTask(t, db.TaskCreateOptions{})
|
||||
pg, ic := g.m.pg, g.m.interceptor
|
||||
store := task.Store
|
||||
intent, err := store.AddIntent(map[string]any{"summary": "leased"}, 1, nil, "planner")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
token, err := store.ClaimIntentToken(intent, "work#1")
|
||||
if err != nil || token == "" {
|
||||
t.Fatalf("claim: token=%q err=%v", token, err)
|
||||
}
|
||||
ctx, cancel := context.WithCancelCause(intercept.WithTaskContext(context.Background(), task.ID, "work#1", nil))
|
||||
defer cancel(nil)
|
||||
stop := startClaimHeartbeat(store, 20*time.Millisecond, "work#1", intent, token, cancel)
|
||||
defer stop()
|
||||
|
||||
scope := task.targetScope(time.Minute)
|
||||
targets := []intercept.TargetRequest{{Host: "app.example.com", Port: 443, ResolvedIPs: []string{"203.0.113.7"}, Resolver: "system", ResolvedAt: time.Now().UTC(), Reason: "no_pin"}}
|
||||
input := []byte(`{"url":"https://app.example.com/"}`)
|
||||
stale := make(chan intercept.TargetResult, 1)
|
||||
go func() { stale <- ic.RequestTargets(ctx, scope, "WebFetch", input, targets, nil) }()
|
||||
openRows := func() []int64 {
|
||||
t.Helper()
|
||||
rows, err := pg.Query(`SELECT id FROM intercept_pending WHERE task_id=$1 AND status='pending' AND target_key IS NOT NULL ORDER BY id`, task.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer rows.Close()
|
||||
var ids []int64
|
||||
for rows.Next() {
|
||||
var id int64
|
||||
if err := rows.Scan(&id); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ids = append(ids, id)
|
||||
}
|
||||
return ids
|
||||
}
|
||||
waitUntil(t, "the gate request", func() bool { return len(openRows()) == 1 })
|
||||
request := openRows()[0]
|
||||
select {
|
||||
case res := <-stale:
|
||||
t.Fatalf("the call returned %+v while its claim was live", res)
|
||||
case <-time.After(200 * time.Millisecond):
|
||||
}
|
||||
|
||||
// Takeover, as in TestClaimHeartbeatRenewsThenCancelsOnTakeover.
|
||||
var reclaimed int64
|
||||
for attempt := 0; attempt < 200 && reclaimed == 0; attempt++ {
|
||||
if _, err := pg.Exec(`UPDATE exploration_nodes SET last_heartbeat_at = now() - $2 * INTERVAL '1 second' WHERE id=$1`,
|
||||
intent, (db.IntentClaimTTL + time.Second).Seconds()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if reclaimed, err = store.ResetRunningIntents(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if reclaimed != 1 {
|
||||
t.Fatalf("could not take the claim over: reclaimed=%d", reclaimed)
|
||||
}
|
||||
if other, err := store.ClaimIntentToken(intent, "work#2"); err != nil || other == "" {
|
||||
t.Fatalf("re-claim: token=%q err=%v", other, err)
|
||||
}
|
||||
|
||||
var res intercept.TargetResult
|
||||
select {
|
||||
case res = <-stale:
|
||||
case <-time.After(10 * time.Second):
|
||||
t.Fatal("the gate wait did not end after the claim was lost")
|
||||
}
|
||||
if cause := context.Cause(ctx); !errors.Is(cause, agent.AbortWorkClaimLost) {
|
||||
t.Fatalf("cancel cause=%v, want AbortWorkClaimLost", cause)
|
||||
}
|
||||
if res.Code != intercept.TargetApprovalPending || res.PendingID != request {
|
||||
t.Fatalf("stale call: %+v, want %s on request %d", res, intercept.TargetApprovalPending, request)
|
||||
}
|
||||
if row, err := pg.GetInterceptPending(request); err != nil || row == nil || row.Status != "pending" || row.DecidedBy != "" {
|
||||
t.Fatalf("request after the claim was lost = %+v, %v; want still open", row, err)
|
||||
}
|
||||
|
||||
// The second claimer re-runs the intent, hits the same host and attaches.
|
||||
next := make(chan intercept.TargetResult, 1)
|
||||
go func() {
|
||||
next <- ic.RequestTargets(intercept.WithTaskContext(context.Background(), task.ID, "work#2", nil), scope, "WebFetch", input, targets, nil)
|
||||
}()
|
||||
select {
|
||||
case res := <-next:
|
||||
t.Fatalf("the second claimer returned %+v before a decision", res)
|
||||
case <-time.After(200 * time.Millisecond):
|
||||
}
|
||||
if ids := openRows(); !slices.Equal(ids, []int64{request}) {
|
||||
t.Fatalf("open requests = %v, want the second claimer on the same request %d", ids, request)
|
||||
}
|
||||
if err := ic.Decide(request, true, "ARTEX", "192.0.2.1"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
select {
|
||||
case res := <-next:
|
||||
if res.Code != "" {
|
||||
t.Fatalf("second claimer after the approval: %+v", res)
|
||||
}
|
||||
case <-time.After(10 * time.Second):
|
||||
t.Fatal("the second claimer is still waiting after the approval")
|
||||
}
|
||||
if pins, err := pg.ListTargetPins(taskRef(t, task)); err != nil || len(pins) != 1 {
|
||||
t.Fatalf("pins = %+v, %v; want one", pins, err)
|
||||
}
|
||||
}
|
||||
@@ -335,8 +335,11 @@ func TestEveryAgentKindCarriesTargetScope(t *testing.T) {
|
||||
if got.scope.WaitBound != site.wait {
|
||||
t.Fatalf("wait bound = %v, want %v", got.scope.WaitBound, site.wait)
|
||||
}
|
||||
if got.scope.Enabled != nil {
|
||||
t.Fatal("the gate must be off: tasks have no opt-in flag yet")
|
||||
if got.scope.Enabled == nil || got.scope.Enabled() {
|
||||
t.Fatal("the gate must be off: the task did not opt in")
|
||||
}
|
||||
if got.scope.Interceptor != f.m.interceptor {
|
||||
t.Fatal("the scope does not carry the interceptor that owns the gate")
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -348,8 +351,11 @@ func TestEveryAgentKindCarriesTargetScope(t *testing.T) {
|
||||
if got.scope.WaitBound != intercept.DefaultTargetWaitBound {
|
||||
t.Fatalf("wait bound = %v, want %v", got.scope.WaitBound, intercept.DefaultTargetWaitBound)
|
||||
}
|
||||
if got.scope.Enabled != nil {
|
||||
t.Fatal("the gate must be off: conversations have no opt-in flag yet")
|
||||
if got.scope.Enabled == nil || got.scope.Enabled() {
|
||||
t.Fatal("the gate must be off: the conversation did not opt in")
|
||||
}
|
||||
if got.scope.Interceptor != f.m.interceptor {
|
||||
t.Fatal("the scope does not carry the interceptor that owns the gate")
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -376,7 +382,7 @@ func TestFinalPlannerRoundHasZeroWait(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// The gate seam is a no-op: through the task's target-only hooks a call is
|
||||
// With the gate off (the task default) the target-only hooks are a no-op: a call is
|
||||
// allowed and nothing is written — no intercept row, no audit-ring entry. The
|
||||
// control runs the same call through the task's full Guard, where a deny rule
|
||||
// blocks it and logs a row, so the rule and the row count are known to be live.
|
||||
|
||||
@@ -0,0 +1,368 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http/httptest"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Autumn-27/artex/db"
|
||||
"github.com/Autumn-27/artex/intercept"
|
||||
"github.com/Autumn-27/artex/traffic"
|
||||
actool "github.com/Autumn-27/norma/tool"
|
||||
)
|
||||
|
||||
// The gate wired into the server: the exemption API, the exemptions derived
|
||||
// from the configuration, and the hooks of a task's guards.
|
||||
|
||||
// exemptionsOf decodes the "exemptions" (or "derived") list of a response.
|
||||
func exemptionsOf(t *testing.T, body map[string]any, field string) []db.TargetExemption {
|
||||
t.Helper()
|
||||
raw, err := json.Marshal(body[field])
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var out []db.TargetExemption
|
||||
if err := json.Unmarshal(raw, &out); err != nil {
|
||||
t.Fatalf("%s = %s: %v", field, raw, err)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func TestTargetGateExemptionsAPI(t *testing.T) {
|
||||
g := newGateServer(t)
|
||||
pg := g.m.pg
|
||||
original, _, err := pg.TargetExemptions()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = pg.ReplaceTargetExemptions(original, "test", "restore") })
|
||||
|
||||
// The routes are behind the session, like the rest of the API.
|
||||
for _, c := range []struct{ method, path string }{
|
||||
{"GET", "/api/target-gate/exemptions"}, {"PUT", "/api/target-gate/exemptions"}, {"GET", "/api/target-gate/exemptions/history"},
|
||||
} {
|
||||
rec := httptest.NewRecorder()
|
||||
g.s.Handler().ServeHTTP(rec, httptest.NewRequest(c.method, c.path, strings.NewReader(`{"exemptions":[]}`)))
|
||||
if rec.Code != 401 {
|
||||
t.Fatalf("%s %s without a token: %d, want 401", c.method, c.path, rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
code, body := g.do(t, "GET", "/api/target-gate/exemptions", "")
|
||||
if code != 200 || !slices.Equal(exemptionsOf(t, body, "exemptions"), original) || body["digest"] == "" || body["derived"] == nil {
|
||||
t.Fatalf("GET: %d %v, want the stored list %+v", code, body, original)
|
||||
}
|
||||
|
||||
// PUT replaces the list. Hosts are normalised; host and port are exact.
|
||||
code, body = g.do(t, "PUT", "/api/target-gate/exemptions",
|
||||
`{"exemptions":[{"host":"Mirror.Example.ORG.","port":443,"note":"internal mirror"},{"host":"10.0.0.5","port":8080},{"host":"FD12:3456::5","port":8080}]}`)
|
||||
want := []db.TargetExemption{{Host: "mirror.example.org", Port: 443, Note: "internal mirror"}, {Host: "10.0.0.5", Port: 8080}, {Host: "fd12:3456::5", Port: 8080}}
|
||||
if code != 200 || !slices.Equal(exemptionsOf(t, body, "exemptions"), want) {
|
||||
t.Fatalf("PUT: %d %v, want %+v", code, body, want)
|
||||
}
|
||||
digest, _ := body["digest"].(string)
|
||||
code, body = g.do(t, "GET", "/api/target-gate/exemptions", "")
|
||||
if code != 200 || !slices.Equal(exemptionsOf(t, body, "exemptions"), want) || body["digest"] != digest || digest == "" {
|
||||
t.Fatalf("GET after PUT: %d %v", code, body)
|
||||
}
|
||||
|
||||
// Refused entries: 400, and the stored list does not change.
|
||||
for name, entry := range map[string]string{
|
||||
"metadata address": `{"host":"169.254.169.254","port":80}`,
|
||||
"metadata address, v6": `{"host":"fd00:ec2::254","port":80}`,
|
||||
"metadata address, cloud": `{"host":"100.100.100.200","port":80}`,
|
||||
"link-local v6": `{"host":"fe80::1","port":80}`,
|
||||
"loopback": `{"host":"127.0.0.1","port":8080}`,
|
||||
"loopback as a number": `{"host":"2130706433","port":8080}`,
|
||||
"loopback v6": `{"host":"::1","port":8080}`,
|
||||
"unspecified": `{"host":"0.0.0.0","port":80}`,
|
||||
"multicast": `{"host":"224.0.0.1","port":80}`,
|
||||
"wildcard": `{"host":"*.example.org","port":443}`,
|
||||
"suffix": `{"host":".example.org","port":443}`,
|
||||
"range": `{"host":"10.0.0.0/8","port":443}`,
|
||||
"url": `{"host":"https://mirror.example.org/","port":443}`,
|
||||
"host with a port": `{"host":"mirror.example.org:443","port":443}`,
|
||||
"no port": `{"host":"mirror.example.org"}`,
|
||||
"port out of range": `{"host":"mirror.example.org","port":70000}`,
|
||||
"no host": `{"port":443}`,
|
||||
"one bad entry among good": `{"host":"pypi.org","port":443},{"host":"169.254.169.254","port":80}`,
|
||||
} {
|
||||
code, body := g.do(t, "PUT", "/api/target-gate/exemptions", `{"exemptions":[`+entry+`]}`)
|
||||
if code != 400 || body["error"] == nil {
|
||||
t.Errorf("%s: %d %v, want 400", name, code, body)
|
||||
}
|
||||
}
|
||||
if code, _ := g.do(t, "PUT", "/api/target-gate/exemptions", `not json`); code != 400 {
|
||||
t.Fatalf("malformed body: %d, want 400", code)
|
||||
}
|
||||
if list, _, err := pg.TargetExemptions(); err != nil || !slices.Equal(list, want) {
|
||||
t.Fatalf("stored list after the refused changes = %+v, %v; want it unchanged", list, err)
|
||||
}
|
||||
|
||||
// The history holds the one change that was made, with who made it.
|
||||
code, body = g.do(t, "GET", "/api/target-gate/exemptions/history", "")
|
||||
changes, _ := body["changes"].([]any)
|
||||
if code != 200 || len(changes) == 0 {
|
||||
t.Fatalf("history: %d %v", code, body)
|
||||
}
|
||||
last, _ := changes[0].(map[string]any)
|
||||
if last["changed_by"] != "ARTEX" || last["digest"] != digest || len(exemptionsOf(t, last, "after")) != 3 || !slices.Equal(exemptionsOf(t, last, "before"), original) {
|
||||
t.Fatalf("last change = %v", last)
|
||||
}
|
||||
|
||||
// An empty list is a valid list.
|
||||
if code, body := g.do(t, "PUT", "/api/target-gate/exemptions", `{}`); code != 200 || len(exemptionsOf(t, body, "exemptions")) != 0 {
|
||||
t.Fatalf("empty list: %d %v", code, body)
|
||||
}
|
||||
if code, body := g.do(t, "GET", "/api/target-gate/exemptions", ""); code != 200 || body["exemptions"] == nil || len(exemptionsOf(t, body, "exemptions")) != 0 {
|
||||
t.Fatalf("GET of the empty list: %d %v", code, body)
|
||||
}
|
||||
}
|
||||
|
||||
// The exemptions of the platform's own endpoints are derived from the
|
||||
// configuration on every call: LLM gateways, profile proxies, the search
|
||||
// backend in use, the search proxy and the global egress proxy.
|
||||
func TestTargetGateEndpointsFollowConfig(t *testing.T) {
|
||||
g := newGateServer(t)
|
||||
derived := func() []string {
|
||||
var out []string
|
||||
for _, e := range g.m.interceptor.DerivedExemptions() {
|
||||
out = append(out, db.TargetKey(e.Host, e.Port))
|
||||
}
|
||||
return out
|
||||
}
|
||||
has := func(key string) bool { return slices.Contains(derived(), key) }
|
||||
|
||||
name := fmt.Sprintf("target-gate-endpoints-%d", time.Now().UnixNano())
|
||||
id, err := g.m.pg.SaveProfile(&db.LLMProfile{Name: name, Format: "openai", BaseURL: "https://LLM-gateway.example.net:8443/v1",
|
||||
Proxy: "http://user:secret@llm-proxy.example.net:3128", Model: "m", APIKey: "k"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = g.m.pg.DeleteProfile(id) })
|
||||
for _, key := range []string{"llm-gateway.example.net:8443", "llm-proxy.example.net:3128"} {
|
||||
if !has(key) {
|
||||
t.Fatalf("derived = %v, want the profile endpoint %s", derived(), key)
|
||||
}
|
||||
}
|
||||
for _, e := range derived() {
|
||||
if strings.Contains(e, "secret") || strings.Contains(e, "user") {
|
||||
t.Fatalf("a derived exemption carries credentials: %s", e)
|
||||
}
|
||||
}
|
||||
|
||||
on, backend, _, _, proxy := g.m.WebSearch()
|
||||
globalProxy := g.m.GlobalProxy()
|
||||
t.Cleanup(func() {
|
||||
_ = g.m.SetWebSearch(on, backend, nil, nil, &proxy)
|
||||
_ = g.m.SetGlobalProxy(globalProxy)
|
||||
})
|
||||
searchProxy := "http://search-proxy.example.net:8118"
|
||||
if err := g.m.SetWebSearch(true, "tavily", nil, nil, &searchProxy); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := g.m.SetGlobalProxy("socks5://egress.example.net:1080"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, key := range []string{"api.tavily.com:443", "search-proxy.example.net:8118", "egress.example.net:1080"} {
|
||||
if !has(key) {
|
||||
t.Fatalf("derived = %v, want %s", derived(), key)
|
||||
}
|
||||
}
|
||||
// The configuration changes: so do the exemptions.
|
||||
none := ""
|
||||
if err := g.m.SetWebSearch(true, "ddgs", nil, nil, &none); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := g.m.SetGlobalProxy(""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := g.m.pg.DeleteProfile(id); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !has("html.duckduckgo.com:443") {
|
||||
t.Fatalf("derived = %v, want the keyless search backend", derived())
|
||||
}
|
||||
for _, key := range []string{"api.tavily.com:443", "search-proxy.example.net:8118", "egress.example.net:1080", "llm-gateway.example.net:8443", "llm-proxy.example.net:3128"} {
|
||||
if has(key) {
|
||||
t.Fatalf("derived = %v, still holds %s after the configuration changed", derived(), key)
|
||||
}
|
||||
}
|
||||
// Search off: its backend is not exempt.
|
||||
if err := g.m.SetWebSearch(false, "ddgs", nil, nil, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if has("html.duckduckgo.com:443") {
|
||||
t.Fatalf("derived = %v, want no search backend while search is off", derived())
|
||||
}
|
||||
// The API shows them, read-only.
|
||||
if code, body := g.do(t, "GET", "/api/target-gate/exemptions", ""); code != 200 || body["derived"] == nil {
|
||||
t.Fatalf("GET: %d %v", code, body)
|
||||
}
|
||||
}
|
||||
|
||||
// An approval of a request that displays no address is refused with 422. The
|
||||
// request stays open and nothing is pinned.
|
||||
func TestInterceptDecideRefusesEmptyPinOverHTTP(t *testing.T) {
|
||||
g := newGateServer(t)
|
||||
task := g.newTask(t, db.TaskCreateOptions{})
|
||||
scope := taskRef(t, task)
|
||||
id := openTargetRow(t, g.m.pg, scope, "no-address.example.com")
|
||||
code, body := g.do(t, "POST", fmt.Sprintf("/api/intercept/pending/%d/decide", id), `{"decision":"allowed"}`)
|
||||
if msg, _ := body["error"].(string); code != 422 || !strings.Contains(msg, "never pins an empty address list") {
|
||||
t.Fatalf("approve: %d %v, want 422", code, body)
|
||||
}
|
||||
if row, err := g.m.pg.GetInterceptPending(id); err != nil || row == nil || row.Status != "pending" {
|
||||
t.Fatalf("row = %+v, %v; want still pending", row, err)
|
||||
}
|
||||
if pins, err := g.m.pg.ListTargetPins(scope); err != nil || len(pins) != 0 {
|
||||
t.Fatalf("pins = %+v, %v; want none", pins, err)
|
||||
}
|
||||
if code, _ := g.do(t, "POST", fmt.Sprintf("/api/intercept/pending/%d/decide", id), `{"decision":"denied"}`); code != 200 {
|
||||
t.Fatalf("deny: %d", code)
|
||||
}
|
||||
}
|
||||
|
||||
// The hooks of a task run the gate with the task's own scope: the worker
|
||||
// guard and the target-only guard of the planner and the main agent. The flag
|
||||
// is the task's, read on every call.
|
||||
func TestTaskGuardsRunTargetGate(t *testing.T) {
|
||||
g := newGateServer(t)
|
||||
task := g.newTask(t, db.TaskCreateOptions{})
|
||||
scope := taskRef(t, task)
|
||||
pg := g.m.pg
|
||||
const fetch = `{"url":"http://203.0.113.7:8080/admin"}`
|
||||
ctxOf := func(agentName string, wait time.Duration) context.Context {
|
||||
return intercept.WithTargetScope(intercept.WithTaskContext(context.Background(), task.ID, agentName, nil), task.targetScope(wait))
|
||||
}
|
||||
targetRows := func() int {
|
||||
t.Helper()
|
||||
var n int
|
||||
if err := pg.QueryRow(`SELECT count(*) FROM intercept_pending WHERE task_id=$1 AND decision_source='target'`, task.ID).Scan(&n); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return n
|
||||
}
|
||||
hook := func(hooks interface {
|
||||
PreToolUse(context.Context, string, []byte) (bool, string, []byte)
|
||||
}, ctx context.Context) (bool, string) {
|
||||
block, msg, _ := hooks.PreToolUse(ctx, "WebFetch", []byte(fetch))
|
||||
return block, msg
|
||||
}
|
||||
|
||||
// Gate off, the default: both guards pass the call and write nothing.
|
||||
for name, hooks := range map[string]interface {
|
||||
PreToolUse(context.Context, string, []byte) (bool, string, []byte)
|
||||
}{"worker": task.Guard.Hooks(), "planner": task.TargetGuard.Hooks()} {
|
||||
if block, msg := hook(hooks, ctxOf(name, 0)); block {
|
||||
t.Fatalf("gate off, %s: blocked: %s", name, msg)
|
||||
}
|
||||
}
|
||||
if n := targetRows(); n != 0 {
|
||||
t.Fatalf("gate off: %d target rows, want none", n)
|
||||
}
|
||||
|
||||
// Gate on through the API: the next call of each guard is stopped. With
|
||||
// wait bound 0 it does not wait, and the one request stays open.
|
||||
if code, _ := g.do(t, "PATCH", "/api/tasks/"+task.ID, `{"target_gate":true}`); code != 200 {
|
||||
t.Fatalf("switch the gate on: %d", code)
|
||||
}
|
||||
for _, c := range []struct {
|
||||
name string
|
||||
hooks interface {
|
||||
PreToolUse(context.Context, string, []byte) (bool, string, []byte)
|
||||
}
|
||||
}{{"planner", task.TargetGuard.Hooks()}, {"worker", task.Guard.Hooks()}} {
|
||||
block, msg := hook(c.hooks, ctxOf(c.name, 0))
|
||||
if !block || !strings.Contains(msg, "TARGET_APPROVAL_PENDING: target 203.0.113.7:8080: ") {
|
||||
t.Fatalf("gate on, %s: block=%v msg=%q", c.name, block, msg)
|
||||
}
|
||||
}
|
||||
var id int64
|
||||
var agentName string
|
||||
var open int
|
||||
if err := pg.QueryRow(`SELECT count(*), min(id), min(agent_name) FROM intercept_pending
|
||||
WHERE task_id=$1 AND status='pending' AND target_key='203.0.113.7:8080'`, task.ID).Scan(&open, &id, &agentName); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if open != 1 || agentName != "planner" {
|
||||
t.Fatalf("%d open requests by %q, want one, opened by the planner's call", open, agentName)
|
||||
}
|
||||
// The planner's block is in the ring of its own guard, not in the task's.
|
||||
if audit := task.TargetGuard.Audit(); len(audit) != 1 || audit[0].Action != "block" {
|
||||
t.Fatalf("target-only ring = %+v, want the one block", audit)
|
||||
}
|
||||
for _, e := range task.Guard.Audit() {
|
||||
if e.Action == "block" && !strings.Contains(e.Reason, "TARGET_APPROVAL_PENDING") {
|
||||
t.Fatalf("unexpected entry in the task ring: %+v", e)
|
||||
}
|
||||
}
|
||||
|
||||
// One click through the API pins the address for the task; both guards pass.
|
||||
if code, _ := g.do(t, "POST", fmt.Sprintf("/api/intercept/pending/%d/decide", id), `{"decision":"allowed"}`); code != 200 {
|
||||
t.Fatalf("decide: %d", code)
|
||||
}
|
||||
if pins, err := pg.ListTargetPins(scope); err != nil || len(pins) != 1 || pins[0].Host != "203.0.113.7" || pins[0].Port != 8080 ||
|
||||
!slices.Equal(pins[0].PinnedIPs, []string{"203.0.113.7"}) || pins[0].DecidedBy != "ARTEX" {
|
||||
t.Fatalf("pins = %+v, %v", pins, err)
|
||||
}
|
||||
ring := len(task.TargetGuard.Audit())
|
||||
for name, hooks := range map[string]interface {
|
||||
PreToolUse(context.Context, string, []byte) (bool, string, []byte)
|
||||
}{"worker": task.Guard.Hooks(), "planner": task.TargetGuard.Hooks()} {
|
||||
if block, msg := hook(hooks, ctxOf(name, 0)); block {
|
||||
t.Fatalf("pinned, %s: blocked: %s", name, msg)
|
||||
}
|
||||
}
|
||||
if n := len(task.TargetGuard.Audit()); n != ring {
|
||||
t.Fatalf("a passing planner call added %d entries to the ring", n-ring)
|
||||
}
|
||||
// Gate off again: the gate is out of the way at once.
|
||||
if code, _ := g.do(t, "PATCH", "/api/tasks/"+task.ID, `{"target_gate":false}`); code != 200 {
|
||||
t.Fatal("switch the gate off")
|
||||
}
|
||||
rows := targetRows()
|
||||
if block, _, _ := task.Guard.Hooks().PreToolUse(ctxOf("worker", 0), "WebFetch", []byte(`{"url":"http://198.51.100.200/"}`)); block || targetRows() != rows {
|
||||
t.Fatalf("gate off again: block=%v, %d new target rows", block, targetRows()-rows)
|
||||
}
|
||||
}
|
||||
|
||||
// The platform's own tools read and write the platform's records: the gate
|
||||
// must find no target in their input, or every finding and every intent that
|
||||
// mentions a URL would ask for an approval. A tool added to these sets has to
|
||||
// be added to the gate's list of tools without a target.
|
||||
func TestHostToolsDeclareNoTarget(t *testing.T) {
|
||||
s := &Server{}
|
||||
var tools []actool.CoreTool
|
||||
tools = append(tools, s.orchestrationTools()...)
|
||||
tools = append(tools, s.findingRetestTools()...)
|
||||
tools = append(tools, s.platformTools()...)
|
||||
tools = append(tools, traffic.SeedToolMetas()...)
|
||||
if len(tools) < 25 {
|
||||
t.Fatalf("only %d host tools found", len(tools))
|
||||
}
|
||||
// The tools that set up a custom tool or an MCP server take an endpoint of
|
||||
// the model's choosing. They are scanned like any unknown tool: that
|
||||
// endpoint is a target.
|
||||
scanned := map[string]bool{"create_custom_tool": true, "update_custom_tool": true, "create_mcp": true, "update_mcp": true, "mcp_status": true}
|
||||
input := []byte(`{"task_id":1,"description":"test https://app.example.com/ and 203.0.113.7:8443","command":"curl https://app.example.com/","q":"host:app.example.com:443"}`)
|
||||
for _, tool := range tools {
|
||||
targets := intercept.ExtractTargets(tool.Name(), input)
|
||||
if scanned[tool.Name()] {
|
||||
if len(targets) == 0 {
|
||||
t.Errorf("%s: no target read", tool.Name())
|
||||
}
|
||||
delete(scanned, tool.Name())
|
||||
} else if len(targets) != 0 {
|
||||
t.Errorf("%s: the gate reads targets %+v from a platform tool", tool.Name(), targets)
|
||||
}
|
||||
}
|
||||
if len(scanned) != 0 {
|
||||
t.Errorf("tools that no longer exist: %v", scanned)
|
||||
}
|
||||
}
|
||||
@@ -28,6 +28,7 @@ func (s *Server) updateTaskMetadata(w http.ResponseWriter, r *http.Request) {
|
||||
Name *string `json:"name"`
|
||||
Pinned *bool `json:"pinned"`
|
||||
PlanQuietMode *bool `json:"plan_quiet_mode"`
|
||||
TargetGate *bool `json:"target_gate"`
|
||||
}
|
||||
if err := decode(r, &request); err != nil {
|
||||
var tooLarge *http.MaxBytesError
|
||||
@@ -38,8 +39,8 @@ func (s *Server) updateTaskMetadata(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
return
|
||||
}
|
||||
if request.Name == nil && request.Pinned == nil && request.PlanQuietMode == nil {
|
||||
writeErr(w, http.StatusBadRequest, "at least one of name, pinned or plan_quiet_mode must be provided")
|
||||
if request.Name == nil && request.Pinned == nil && request.PlanQuietMode == nil && request.TargetGate == nil {
|
||||
writeErr(w, http.StatusBadRequest, "at least one of name, pinned, plan_quiet_mode or target_gate must be provided")
|
||||
return
|
||||
}
|
||||
if request.Name != nil {
|
||||
@@ -54,7 +55,7 @@ func (s *Server) updateTaskMetadata(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
request.Name = &name
|
||||
}
|
||||
task, err := s.m.UpdateTaskMetadata(taskID, db.TaskPatch{Name: request.Name, Pinned: request.Pinned, PlanQuietMode: request.PlanQuietMode})
|
||||
task, err := s.m.UpdateTaskMetadata(taskID, db.TaskPatch{Name: request.Name, Pinned: request.Pinned, PlanQuietMode: request.PlanQuietMode, TargetGate: request.TargetGate})
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
|
||||
Reference in New Issue
Block a user