mirror of
https://github.com/BaaRai/ARTEX.git
synced 2026-11-14 04:00:54 +08:00
Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1c3a5f4ffa | ||
|
|
1e616c9286 | ||
|
|
46e81f5f67 | ||
|
|
c5e12541bd | ||
|
|
743e6b0d15 | ||
|
|
f07e84ac7a |
+1
-4
@@ -128,13 +128,10 @@ func (c *ChatAgent) Chat(ctx context.Context, agentKey, sessionID, message strin
|
||||
Provider: c.prov,
|
||||
SystemPrompt: system,
|
||||
DynamicBoundary: boundary,
|
||||
Tools: tools,
|
||||
Tools: withWebFetch(tools, c.proxyAddr, c.proxyCACert), // WebFetch: routed through the recording proxy for tracing; loads the proxy CA to verify MITM-resigned HTTPS certificates; every redirect hop is gated
|
||||
DeferredTools: def.Deferred,
|
||||
UnlockSet: def.Unlock,
|
||||
PermissionMode: permission.ModeBypass,
|
||||
EnableWebFetch: true, // routed through the recording proxy for tracing; loads the proxy CA to verify MITM-resigned HTTPS certificates
|
||||
WebFetchProxy: c.proxyAddr,
|
||||
WebFetchCACert: c.proxyCACert,
|
||||
// Web search (optional). ddgs needs no key; brave-free needs BraveKey; tavily needs TavilyKey.
|
||||
// WebSearchProxy is a dedicated egress proxy (http/https/socks5), unrelated to the traffic-recording MITM proxy; empty = direct.
|
||||
EnableWebSearch: ws.Enabled,
|
||||
|
||||
+1
-4
@@ -144,13 +144,10 @@ func (m *MainAgent) Chat(ctx context.Context, taskID int64, mainSeg int, as *db.
|
||||
Provider: m.prov,
|
||||
SystemPrompt: system,
|
||||
DynamicBoundary: boundary,
|
||||
Tools: tools,
|
||||
Tools: withWebFetch(tools, m.proxyAddr, m.proxyCACert), // WebFetch: routed through the recording proxy for tracing; loads the proxy CA to verify MITM-resigned HTTPS certificates; every redirect hop is gated
|
||||
DeferredTools: def.Deferred,
|
||||
UnlockSet: def.Unlock,
|
||||
PermissionMode: permission.ModeBypass,
|
||||
EnableWebFetch: true, // routed through the recording proxy for tracing; loads the proxy CA to verify MITM-resigned HTTPS certificates
|
||||
WebFetchProxy: m.proxyAddr,
|
||||
WebFetchCACert: m.proxyCACert,
|
||||
// Web search (optional). ddgs needs no key; brave-free needs BraveKey; tavily needs TavilyKey.
|
||||
// WebSearchProxy is a dedicated egress proxy (http/https/socks5), unrelated to the traffic-recording MITM proxy; empty = direct.
|
||||
EnableWebSearch: m.webSearch.Enabled,
|
||||
|
||||
+1
-4
@@ -426,13 +426,10 @@ func (p *Planner) Plan(ctx context.Context, taskID int64, as *db.AssetStore, ts
|
||||
Provider: p.prov,
|
||||
SystemPrompt: system,
|
||||
DynamicBoundary: boundary,
|
||||
Tools: tools,
|
||||
Tools: withWebFetch(tools, p.proxyAddr, p.proxyCACert), // WebFetch: route through the recording proxy for traceability; load the proxy CA to verify MITM-re-signed HTTPS certs; every redirect hop is gated
|
||||
DeferredTools: def.Deferred,
|
||||
UnlockSet: def.Unlock,
|
||||
PermissionMode: permission.ModeBypass,
|
||||
EnableWebFetch: true, // route through the recording proxy for traceability; load the proxy CA to verify MITM-re-signed HTTPS certs
|
||||
WebFetchProxy: p.proxyAddr,
|
||||
WebFetchCACert: p.proxyCACert,
|
||||
// Web search (optional). ddgs needs no key; brave-free needs BraveKey; tavily needs TavilyKey.
|
||||
// WebSearchProxy is a dedicated egress proxy (http/https/socks5), unrelated to the traffic-recording MITM proxy; empty = direct.
|
||||
EnableWebSearch: p.webSearch.Enabled,
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"github.com/Autumn-27/artex/intercept"
|
||||
actool "github.com/Autumn-27/norma/tool"
|
||||
)
|
||||
|
||||
// withWebFetch returns tools plus the WebFetch tool of an agent session. Every
|
||||
// agent kind builds WebFetch here instead of through agentcore's EnableWebFetch,
|
||||
// which has no option for a redirect policy: every redirect hop goes through the
|
||||
// target gate (intercept.RedirectCheck), on the client that uses proxy and on
|
||||
// the direct retry client alike. The tool is added after the session's own
|
||||
// tools, where agentcore adds it, so the tool list keeps its order. caCert is the
|
||||
// recording proxy's CA, trusted to verify the HTTPS it re-signs. tools is not
|
||||
// modified.
|
||||
func withWebFetch(tools []actool.CoreTool, proxy, caCert string) []actool.CoreTool {
|
||||
webFetch := actool.NewWebFetch(actool.WebFetchConfig{Proxy: proxy, CACert: caCert, CheckRedirect: intercept.RedirectCheck("WebFetch")})
|
||||
return append(tools[:len(tools):len(tools)], webFetch)
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Autumn-27/artex/db"
|
||||
"github.com/Autumn-27/artex/guard"
|
||||
"github.com/Autumn-27/artex/intercept"
|
||||
"github.com/Autumn-27/norma/llm"
|
||||
actool "github.com/Autumn-27/norma/tool"
|
||||
)
|
||||
|
||||
// Step 1.9: the WebFetch tool of every agent kind checks each redirect hop
|
||||
// against the target gate, on the primary client and on the direct retry
|
||||
// client. With the gate off it follows redirects as before.
|
||||
|
||||
// passHooks lets every tool call run.
|
||||
type passHooks struct{}
|
||||
|
||||
func (passHooks) PreToolUse(context.Context, string, []byte) (bool, string, []byte) {
|
||||
return false, "", nil
|
||||
}
|
||||
|
||||
func (passHooks) PostToolUse(context.Context, string, []byte, []byte, bool) {}
|
||||
|
||||
func (passHooks) Stop(context.Context, []llm.Message) (bool, []string, string) {
|
||||
return false, nil, ""
|
||||
}
|
||||
|
||||
// redirectPair is a server that redirects to a second one on another port of
|
||||
// 127.0.0.1, so the hop is another target (an IP literal: never resolved).
|
||||
type redirectPair struct {
|
||||
start, final string // URLs
|
||||
startHits, finalHits *atomic.Int64
|
||||
finalKey string // host:port of the redirect target
|
||||
}
|
||||
|
||||
func newRedirectPair(t *testing.T) redirectPair {
|
||||
t.Helper()
|
||||
p := redirectPair{startHits: &atomic.Int64{}, finalHits: &atomic.Int64{}}
|
||||
final := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
p.finalHits.Add(1)
|
||||
w.Header().Set("Content-Type", "text/plain")
|
||||
_, _ = w.Write([]byte("redirect-target-reached"))
|
||||
}))
|
||||
t.Cleanup(final.Close)
|
||||
start := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
p.startHits.Add(1)
|
||||
http.Redirect(w, r, final.URL+"/final", http.StatusFound)
|
||||
}))
|
||||
t.Cleanup(start.Close)
|
||||
u, _ := url.Parse(final.URL)
|
||||
p.start, p.final, p.finalKey = start.URL+"/start", final.URL+"/final", u.Host
|
||||
return p
|
||||
}
|
||||
|
||||
// gatedScope is a gated target scope on task taskID, whose rows are removed
|
||||
// afterwards.
|
||||
func gatedScope(t *testing.T, d *db.DB, taskID string) intercept.TargetScope {
|
||||
t.Helper()
|
||||
t.Cleanup(func() { _, _ = d.Exec(`DELETE FROM intercept_pending WHERE task_id=$1`, taskID) })
|
||||
return intercept.TargetScope{TaskID: taskID, Enabled: func() bool { return true },
|
||||
WaitBound: intercept.DefaultTargetWaitBound, Interceptor: intercept.New(d)}
|
||||
}
|
||||
|
||||
// openRedirectRequests returns the target keys of the open requests of task.
|
||||
func openRedirectRequests(t *testing.T, d *db.DB, taskID string) []string {
|
||||
t.Helper()
|
||||
rows, err := d.Query(`SELECT target_key FROM intercept_pending WHERE task_id=$1 AND status='pending' AND target_key IS NOT NULL ORDER BY id`, taskID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer rows.Close()
|
||||
var keys []string
|
||||
for rows.Next() {
|
||||
var k string
|
||||
if err := rows.Scan(&k); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
keys = append(keys, k)
|
||||
}
|
||||
return keys
|
||||
}
|
||||
|
||||
func fetchInputJSON(u string) string {
|
||||
b, _ := json.Marshal(map[string]string{"url": u})
|
||||
return string(b)
|
||||
}
|
||||
|
||||
// Each agent kind runs a scripted WebFetch on a URL that redirects to a target
|
||||
// no pin covers. The hooks let the call run, so the hop is what the gate sees:
|
||||
// each kind must stop at the hop, leave the redirect target untouched and open
|
||||
// one request for it. A fifth agent kind added without the redirect policy
|
||||
// fails this table once it is added to it.
|
||||
func TestWebFetchRedirectCheckWiredOnEveryAgentKind(t *testing.T) {
|
||||
for _, kind := range []string{"worker", "planner", "mainagent", "chat"} {
|
||||
t.Run(kind, func(t *testing.T) {
|
||||
f := newHookReachFixture(t)
|
||||
d := testDB(t)
|
||||
t.Cleanup(func() { _ = d.Close() })
|
||||
pair := newRedirectPair(t)
|
||||
scope := gatedScope(t, d, f.taskID)
|
||||
ctx := intercept.WithTargetScope(t.Context(), scope)
|
||||
prov := toolCallThenStop("WebFetch", fetchInputJSON(pair.start))
|
||||
|
||||
switch kind {
|
||||
case "worker":
|
||||
payload := map[string]any{"summary": "redirect probe"}
|
||||
intentID, err := f.ts.AddNode("intent", payload, 0, "running", "planner", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
raw, _ := json.Marshal(payload)
|
||||
w := NewWorker(prov, "test-model", f.workDir, nil, 0, 3)
|
||||
if _, _, err := w.Execute(ctx, "work#1", f.expID, nil, f.ts, &db.Node{ID: intentID, Payload: raw}, passHooks{}, nil, nil, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
case "planner":
|
||||
p := NewPlanner(prov, "test-model", f.workDir, nil, 0, 3)
|
||||
if _, _, err := p.Plan(ctx, f.expID, nil, f.ts, "probe goal", nil, passHooks{}, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
case "mainagent":
|
||||
m := NewMainAgent(prov, "test-model", f.workDir, nil, 0, 3)
|
||||
if _, err := m.Chat(ctx, f.expID, 0, nil, f.ts, "probe goal", "status?", passHooks{}, nil, nil, nil, nil, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
case "chat":
|
||||
c := NewChatAgent(prov, "test-model", t.TempDir(), nil, 0)
|
||||
c.SetGuard(guard.New())
|
||||
if _, err := c.Chat(ctx, "assistant", "conv-redirect-probe", "fetch it", 3, 0, false, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
if pair.startHits.Load() != 1 {
|
||||
t.Fatalf("the fetch did not run: %d hits on the origin", pair.startHits.Load())
|
||||
}
|
||||
if pair.finalHits.Load() != 0 {
|
||||
t.Fatal("the redirect was followed to a target no pin covers")
|
||||
}
|
||||
if keys := openRedirectRequests(t, d, f.taskID); len(keys) != 1 || keys[0] != pair.finalKey {
|
||||
t.Fatalf("open requests = %v, want one for %s", keys, pair.finalKey)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// The direct retry client carries the policy too: with a proxy that refuses
|
||||
// the connection, WebFetch retries without it, reaches the origin, and the
|
||||
// hop is still stopped.
|
||||
func TestWebFetchRedirectCheckOnDirectRetry(t *testing.T) {
|
||||
d := testDB(t)
|
||||
t.Cleanup(func() { _ = d.Close() })
|
||||
expID, err := d.CreateExploration("redirect retry probe", "the direct retry client checks redirects")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _, _ = d.Exec(`DELETE FROM explorations WHERE id=$1`, expID) })
|
||||
taskID := fmt.Sprint(expID)
|
||||
pair := newRedirectPair(t)
|
||||
ctx := intercept.WithTargetScope(t.Context(), gatedScope(t, d, taskID))
|
||||
|
||||
out, isErr := callWebFetch(t, ctx, "http://127.0.0.1:1", pair.start)
|
||||
if !isErr || !strings.Contains(out, intercept.TargetApprovalPending) || !strings.Contains(out, "redirects to "+pair.finalKey) {
|
||||
t.Fatalf("retried fetch: error=%v out=%q", isErr, out)
|
||||
}
|
||||
if pair.startHits.Load() != 1 || pair.finalHits.Load() != 0 {
|
||||
t.Fatalf("hits origin=%d target=%d, want the origin reached by the retry and the target untouched", pair.startHits.Load(), pair.finalHits.Load())
|
||||
}
|
||||
if keys := openRedirectRequests(t, d, taskID); len(keys) != 1 || keys[0] != pair.finalKey {
|
||||
t.Fatalf("open requests = %v, want one for %s", keys, pair.finalKey)
|
||||
}
|
||||
}
|
||||
|
||||
// With the gate off, or without a scope, WebFetch follows redirects as it did
|
||||
// with agentcore's own tool: to the end of the chain, and the same error after
|
||||
// 10 hops. No database.
|
||||
func TestWebFetchRedirectGateOffUnchanged(t *testing.T) {
|
||||
loop := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
http.Redirect(w, r, r.URL.Path+"x", http.StatusFound)
|
||||
}))
|
||||
t.Cleanup(loop.Close)
|
||||
for name, ctx := range map[string]context.Context{
|
||||
"no scope": t.Context(),
|
||||
"flag off": intercept.WithTargetScope(t.Context(), intercept.TargetScope{TaskID: "7", Enabled: func() bool { return false },
|
||||
WaitBound: time.Minute, Interceptor: intercept.New(nil)}),
|
||||
} {
|
||||
pair := newRedirectPair(t)
|
||||
for _, proxy := range []string{"", "http://127.0.0.1:1"} {
|
||||
if out, isErr := callWebFetch(t, ctx, proxy, pair.start); isErr || !strings.Contains(out, "redirect-target-reached") {
|
||||
t.Fatalf("%s, proxy %q: error=%v out=%q", name, proxy, isErr, out)
|
||||
}
|
||||
}
|
||||
if pair.finalHits.Load() != 2 {
|
||||
t.Fatalf("%s: %d hits on the redirect target, want 2", name, pair.finalHits.Load())
|
||||
}
|
||||
got, _ := callWebFetch(t, ctx, "", loop.URL+"/l")
|
||||
want := actool.NewWebFetch(actool.WebFetchConfig{}).Call
|
||||
res, err := want(ctx, json.RawMessage(fetchInputJSON(loop.URL+"/l")), &actool.ToolContext{})
|
||||
if err != nil || got != res.Flatten() || !strings.Contains(got, "stopped after 10 redirects") {
|
||||
t.Fatalf("%s: redirect loop: %q, want agentcore's %q", name, got, res.Flatten())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// callWebFetch runs the WebFetch tool the agents build, with proxy.
|
||||
func callWebFetch(t *testing.T, ctx context.Context, proxy, u string) (string, bool) {
|
||||
t.Helper()
|
||||
tools := withWebFetch(nil, proxy, "")
|
||||
if len(tools) != 1 || tools[0].Name() != "WebFetch" {
|
||||
t.Fatalf("withWebFetch built %d tools", len(tools))
|
||||
}
|
||||
res, err := tools[0].Call(ctx, json.RawMessage(fetchInputJSON(u)), &actool.ToolContext{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return res.Flatten(), res.IsError
|
||||
}
|
||||
+5
-7
@@ -482,16 +482,14 @@ func (w *Worker) execute(ctx context.Context, name string, taskID int64, as *db.
|
||||
Provider: w.prov,
|
||||
SystemPrompt: system,
|
||||
DynamicBoundary: boundary,
|
||||
Tools: tools,
|
||||
Tools: withWebFetch(tools, w.proxyAddr, w.proxyCACert),
|
||||
DeferredTools: def.Deferred,
|
||||
UnlockSet: def.Unlock,
|
||||
PermissionMode: permission.ModeBypass,
|
||||
// WebFetch goes through the recording proxy, so its HTTP is recorded just like
|
||||
// curl; loading the proxy CA lets MITM-re-signed HTTPS certs [verify normally]
|
||||
// (rather than disabling verification). Direct when proxy is empty.
|
||||
EnableWebFetch: true,
|
||||
WebFetchProxy: w.proxyAddr,
|
||||
WebFetchCACert: w.proxyCACert,
|
||||
// WebFetch (added to Tools above) goes through the recording proxy, so its
|
||||
// HTTP is recorded just like curl; loading the proxy CA lets MITM-re-signed
|
||||
// HTTPS certs [verify normally] (rather than disabling verification). Direct
|
||||
// when proxy is empty. Every redirect hop goes through the target gate.
|
||||
// Web search (optional). ddgs needs no key; brave-free needs BraveKey; tavily needs TavilyKey.
|
||||
// WebSearchProxy is a dedicated egress proxy (http/https/socks5), unrelated to the traffic-recording MITM proxy; empty = direct.
|
||||
EnableWebSearch: w.webSearch.Enabled,
|
||||
|
||||
@@ -76,6 +76,9 @@ type InterceptTarget struct {
|
||||
// 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"`
|
||||
// RedirectFrom: the origin (host:port) of the redirect hop that led to this
|
||||
// target; empty when the call named the target itself.
|
||||
RedirectFrom string `json:"redirect_from,omitempty"`
|
||||
}
|
||||
|
||||
type InterceptDetail struct {
|
||||
|
||||
@@ -61,4 +61,4 @@ require (
|
||||
modernc.org/memory v1.11.0 // indirect
|
||||
)
|
||||
|
||||
replace github.com/Autumn-27/norma => github.com/BaaRai/Norma v0.4.6
|
||||
replace github.com/Autumn-27/norma => github.com/BaaRai/Norma v0.4.7
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
github.com/BaaRai/Norma v0.4.6 h1:sEm58Gx7gw+UU/05aHZBZ+Xqm5dMYiuSQpc1aXnbElc=
|
||||
github.com/BaaRai/Norma v0.4.6/go.mod h1:BudWrf7iHl96FgHqFr/HwN3mKYmGjJDt+xL2ZHXfFZw=
|
||||
github.com/BaaRai/Norma v0.4.7 h1:MJTKF7Dpuh3hjRJpmKtLJ8RknX30FEntDz08Q2y4iDs=
|
||||
github.com/BaaRai/Norma v0.4.7/go.mod h1:BudWrf7iHl96FgHqFr/HwN3mKYmGjJDt+xL2ZHXfFZw=
|
||||
github.com/andybalholm/brotli v1.2.1 h1:R+f5xP285VArJDRgowrfb9DqL18yVK0gKAW/F+eTWro=
|
||||
github.com/andybalholm/brotli v1.2.1/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
|
||||
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 h1:DklsrG3dyBCFEj5IhUbnKptjxatkF07cF2ak3yi77so=
|
||||
|
||||
@@ -110,6 +110,8 @@ type targetCheck struct {
|
||||
tool string
|
||||
input []byte
|
||||
urls []string // the URLs the call declares, as written, for the URL block rules
|
||||
// redirectFrom is the origin (host:port) of a redirect hop; empty for a call.
|
||||
redirectFrom string
|
||||
}
|
||||
|
||||
// checkedTarget is one target of the call and what the gate learned about it.
|
||||
@@ -303,6 +305,9 @@ func (c *targetCheck) log(status, reason, configDigest string, targets ...db.Int
|
||||
// The row records that a pin or an exemption was used, not this one call.
|
||||
audit.ExecutionStatus = "unknown"
|
||||
}
|
||||
for n := range targets {
|
||||
targets[n].RedirectFrom = c.redirectFrom
|
||||
}
|
||||
audit.Targets = targets
|
||||
_, agentName := taskInfoFromCtx(c.ctx)
|
||||
_ = c.i.targets.LogDecision(db.TargetDecision{Scope: c.ref, AgentName: agentName, ToolName: c.tool,
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
package intercept
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// The HTTP clients the platform owns (WebFetch and the custom http tool)
|
||||
// follow redirects. The hook and the rendered-URL gate decide on the URL a call
|
||||
// names; a redirect sends the client to a URL nobody checked. RedirectCheck is
|
||||
// the redirect policy of those clients: the client asks it before it follows
|
||||
// each hop, and the hop goes through the same target decision as the call, on
|
||||
// the URL the client is about to request: the asset block rules on that URL and
|
||||
// on its target, before any lookup, then exemptions, resolution and pins.
|
||||
|
||||
// maxRedirects is the limit net/http applies to a client without a redirect
|
||||
// policy. Setting a policy replaces it, so RedirectCheck restates it, with the
|
||||
// same error text.
|
||||
const maxRedirects = 10
|
||||
|
||||
// RedirectCheck returns the redirect policy (http.Client.CheckRedirect) of a
|
||||
// platform-owned client run by the tool toolName. Everything it needs comes
|
||||
// from the context of the request, which net/http keeps on every hop: the
|
||||
// target scope of the session and the Interceptor that owns its gate.
|
||||
//
|
||||
// With the gate off, or with no scope in the context (the operator's test
|
||||
// endpoint), it only applies the limit of 10 hops: nothing is parsed, resolved,
|
||||
// read or written, and the client behaves as with the default policy.
|
||||
//
|
||||
// A hop no pin or exemption covers is not followed, and the policy never waits:
|
||||
// the fetch has its own deadline, which a human decision does not fit in. The
|
||||
// request row for the hop's target is found or created (it is durable, so an
|
||||
// approval given later lets the retried call follow), and the call fails at
|
||||
// once with TARGET_APPROVAL_PENDING. A block rule, a failed lookup or a hop
|
||||
// whose target cannot be read fails the call with its block code, and opens no
|
||||
// request.
|
||||
func RedirectCheck(toolName string) func(req *http.Request, via []*http.Request) error {
|
||||
return func(req *http.Request, via []*http.Request) error {
|
||||
if len(via) >= maxRedirects {
|
||||
return errors.New("stopped after 10 redirects")
|
||||
}
|
||||
ctx := req.Context()
|
||||
scope, on := renderedScope(ctx)
|
||||
if !on {
|
||||
return nil
|
||||
}
|
||||
return checkRedirectHop(ctx, scope, toolName, req.URL.String(), via[len(via)-1].URL.String())
|
||||
}
|
||||
}
|
||||
|
||||
// checkRedirectHop runs the gate decision on one hop, from the URL that
|
||||
// redirected (from) to the URL the client will request (hop).
|
||||
func checkRedirectHop(ctx context.Context, scope TargetScope, toolName, hop, from string) error {
|
||||
input, _ := json.Marshal(map[string]string{"url": hop, "redirect_from": from})
|
||||
blocked := func(res TargetResult) error {
|
||||
return &redirectError{res: res, from: from, hop: hop, scope: scope.scopeName()}
|
||||
}
|
||||
t, err := urlTarget(hop)
|
||||
if err != nil {
|
||||
return blocked(undeterminedTarget(ctx, scope, toolName, input, "the target of the redirect could not be read ("+err.Error()+")"))
|
||||
}
|
||||
i := scope.Interceptor
|
||||
if i == nil {
|
||||
return blocked(TargetResult{Code: TargetGateUnavailable, Host: t.Host, Port: t.Port, Reason: "the session's target scope carries no gate"})
|
||||
}
|
||||
var origin string
|
||||
if o, err := urlTarget(from); err == nil {
|
||||
origin = o.String()
|
||||
}
|
||||
ctx = WithCall(ctx, toolName, input)
|
||||
c := &targetCheck{i: i, ctx: ctx, scope: scope, tool: toolName, input: input, urls: []string{hop}, redirectFrom: origin}
|
||||
unpinned, res := c.run([]Target{t})
|
||||
if res.Code == "" && len(unpinned) > 0 {
|
||||
// Wait bound 0: the request is found or created, and the call does not wait.
|
||||
scope.WaitBound = 0
|
||||
for n := range unpinned {
|
||||
unpinned[n].RedirectFrom = origin
|
||||
}
|
||||
res = i.RequestTargets(ctx, scope, toolName, input, unpinned, nil)
|
||||
}
|
||||
if res.Code == "" {
|
||||
return nil
|
||||
}
|
||||
return blocked(res)
|
||||
}
|
||||
|
||||
// redirectError is what the client returns when a hop is not followed. Both
|
||||
// tools hand the client error to the model as the tool result.
|
||||
type redirectError struct {
|
||||
res TargetResult
|
||||
from, hop, scope string
|
||||
}
|
||||
|
||||
func (e *redirectError) Error() string {
|
||||
target := e.hop
|
||||
if e.res.Host != "" {
|
||||
target = targetName(TargetRequest{Host: e.res.Host, Port: e.res.Port})
|
||||
}
|
||||
msg := "[ARTEX platform control, not target defense] " + e.res.Code + ": " + e.from + " redirects to " + target
|
||||
if e.res.Code == TargetApprovalPending {
|
||||
return msg + ", which is not approved for " + e.scope + ". The redirect was not followed and an approval was requested. " +
|
||||
"Retry the same call after the approval. Do not fetch the redirect target directly or with another tool."
|
||||
}
|
||||
return msg + ": " + e.res.Reason + ". The redirect was not followed. Do not fetch the redirect target directly or with another tool."
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
package intercept
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Step 1.9 against PostgreSQL: a hop no pin covers opens the durable request
|
||||
// and fails the call at once; an approval lets the same call follow.
|
||||
|
||||
// A hop to an unpinned target is not followed: the redirect target receives
|
||||
// nothing, exactly one request row is opened for it, and the call fails with
|
||||
// TARGET_APPROVAL_PENDING without waiting, although the scope would wait five
|
||||
// minutes at the hook. The same call again attaches to that row. After the
|
||||
// approval it follows.
|
||||
func TestRedirectToUnpinnedHostNotFollowed(t *testing.T) {
|
||||
d := targetTestDB(t)
|
||||
ic, res := pgGate(t, d)
|
||||
scope := taskScope(t, d, ic, 5*time.Minute)
|
||||
ctx := WithTargetScope(t.Context(), scope)
|
||||
final, finalHits, finalKey := hopServer(t, "")
|
||||
start, startHits, startKey := hopServer(t, final.URL+"/final")
|
||||
|
||||
began := time.Now()
|
||||
_, err := gatedFetch(ctx, start.URL+"/start")
|
||||
if err == nil || !strings.Contains(err.Error(), TargetApprovalPending) ||
|
||||
!strings.Contains(err.Error(), start.URL+"/start redirects to "+finalKey) || !strings.Contains(err.Error(), "not followed") {
|
||||
t.Fatalf("unpinned hop: %v", err)
|
||||
}
|
||||
if elapsed := time.Since(began); elapsed > 10*time.Second {
|
||||
t.Fatalf("the hop waited %v", elapsed)
|
||||
}
|
||||
if startHits.Load() != 1 || finalHits.Load() != 0 {
|
||||
t.Fatalf("hits start=%d final=%d, want the redirect target untouched", startHits.Load(), finalHits.Load())
|
||||
}
|
||||
ids := openRows(t, d, scope)
|
||||
if len(ids) != 1 || allRows(t, d, scope) != 1 {
|
||||
t.Fatalf("open requests = %v (%d rows), want one", ids, allRows(t, d, scope))
|
||||
}
|
||||
detail, err := d.GetInterceptDetail(ids[0])
|
||||
if err != nil || detail == nil || detail.Audit == nil || len(detail.Audit.Targets) != 1 {
|
||||
t.Fatalf("request detail = %+v, %v", detail, err)
|
||||
}
|
||||
if tgt := detail.Audit.Targets[0]; tgt.Host+":"+strconv.Itoa(tgt.Port) != finalKey || tgt.RedirectFrom != startKey || tgt.Outcome != "requested" {
|
||||
t.Fatalf("requested target = %+v, want %s from %s", tgt, finalKey, startKey)
|
||||
}
|
||||
if !strings.Contains(detail.Reason, "A redirect from "+startKey) || !strings.Contains(string(detail.ToolInput), start.URL+"/start") {
|
||||
t.Fatalf("request reason %q / input %s, want the origin of the redirect", detail.Reason, detail.ToolInput)
|
||||
}
|
||||
|
||||
// The retried call stops at the same hop and attaches to the same row.
|
||||
if _, err := gatedFetch(ctx, start.URL+"/start"); err == nil || !strings.Contains(err.Error(), TargetApprovalPending) || finalHits.Load() != 0 {
|
||||
t.Fatalf("retry before the approval: %v, final hits %d", err, finalHits.Load())
|
||||
}
|
||||
if again := openRows(t, d, scope); len(again) != 1 || again[0] != ids[0] || allRows(t, d, scope) != 1 {
|
||||
t.Fatalf("after the retry: open %v, %d rows; want the same single request", again, allRows(t, d, scope))
|
||||
}
|
||||
|
||||
approveAll(t, d, ic, scope)
|
||||
if resp, err := gatedFetch(ctx, start.URL+"/start"); err != nil || resp.StatusCode != 200 || finalHits.Load() != 1 {
|
||||
t.Fatalf("after the approval: %v, final hits %d", err, finalHits.Load())
|
||||
}
|
||||
if res.total() != 0 {
|
||||
t.Fatalf("%d lookups for IP literals", res.total())
|
||||
}
|
||||
}
|
||||
|
||||
// http -> https on the same host is another key (port 443, not 80): a pin on
|
||||
// (host, 80) does not cover it, and the hop asks again.
|
||||
func TestRedirectHTTPToHTTPSAsksAgain(t *testing.T) {
|
||||
d := targetTestDB(t)
|
||||
ic, res := pgGate(t, d)
|
||||
res.set("app.example.com", "203.0.113.7")
|
||||
scope := taskScope(t, d, ic, time.Minute)
|
||||
pinURL(t, d, ic, scope, "http://app.example.com/")
|
||||
ctx := WithTargetScope(t.Context(), scope)
|
||||
|
||||
if err := hop(ctx, "http://app.example.com/next", "http://app.example.com/"); err != nil {
|
||||
t.Fatalf("hop on the pinned key: %v", err)
|
||||
}
|
||||
err := hop(ctx, "https://app.example.com/", "http://app.example.com/")
|
||||
if err == nil || !strings.Contains(err.Error(), TargetApprovalPending) || !strings.Contains(err.Error(), "redirects to app.example.com:443") {
|
||||
t.Fatalf("http -> https: %v", err)
|
||||
}
|
||||
ids := openRows(t, d, scope)
|
||||
if len(ids) != 1 {
|
||||
t.Fatalf("open requests = %v, want the one for port 443", ids)
|
||||
}
|
||||
detail, err := d.GetInterceptDetail(ids[0])
|
||||
if err != nil || detail == nil || detail.Audit == nil || detail.Audit.Targets[0].Port != 443 || detail.Audit.Targets[0].RedirectFrom != "app.example.com:80" {
|
||||
t.Fatalf("request = %+v, %v", detail, err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
package intercept
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Autumn-27/artex/db"
|
||||
)
|
||||
|
||||
// Step 1.9: every redirect hop of a platform-owned client goes through the gate.
|
||||
// Without a database: a hop that would open a request is covered by the
|
||||
// PostgreSQL tests.
|
||||
|
||||
// hopServer counts its requests and answers a fixed page, or a redirect to
|
||||
// location when it is set.
|
||||
func hopServer(t *testing.T, location string) (srv *httptest.Server, hits *atomic.Int64, key string) {
|
||||
t.Helper()
|
||||
hits = &atomic.Int64{}
|
||||
srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
hits.Add(1)
|
||||
if location != "" {
|
||||
http.Redirect(w, r, location, http.StatusFound)
|
||||
return
|
||||
}
|
||||
_, _ = w.Write([]byte("hop-reached"))
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
u, err := url.Parse(srv.URL)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return srv, hits, u.Host
|
||||
}
|
||||
|
||||
// hop calls the redirect policy of tool on one hop, as the client does.
|
||||
func hop(ctx context.Context, to string, from ...string) error {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, to, nil)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
via := make([]*http.Request, len(from))
|
||||
for n, f := range from {
|
||||
if via[n], err = http.NewRequestWithContext(ctx, http.MethodGet, f, nil); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
return RedirectCheck("WebFetch")(req, via)
|
||||
}
|
||||
|
||||
func gatedFetch(ctx context.Context, rawURL string) (*http.Response, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resp, err := (&http.Client{CheckRedirect: RedirectCheck("WebFetch")}).Do(req)
|
||||
if resp != nil {
|
||||
resp.Body.Close()
|
||||
}
|
||||
return resp, err
|
||||
}
|
||||
|
||||
// With the gate off, or without a scope, the policy is net/http's default:
|
||||
// every hop is followed, up to 10, with the same error after that. Nothing is
|
||||
// parsed, resolved, read or logged, not even a hop whose target cannot be read.
|
||||
func TestRedirectGateOffKeepsDefaultPolicy(t *testing.T) {
|
||||
g := newGate(t)
|
||||
g.res.set("app.example.com", "203.0.113.7")
|
||||
chain := make([]string, maxRedirects)
|
||||
for n := range chain {
|
||||
chain[n] = "http://app.example.com/"
|
||||
}
|
||||
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}),
|
||||
} {
|
||||
if err := hop(ctx, "https://other.example.com/", chain[:1]...); err != nil {
|
||||
t.Errorf("%s: %v, want the hop followed", name, err)
|
||||
}
|
||||
if err := hop(ctx, "http:///no-host", chain[:1]...); err != nil {
|
||||
t.Errorf("%s, no host: %v, want the hop followed", name, err)
|
||||
}
|
||||
if err := hop(ctx, "https://other.example.com/", chain[:maxRedirects-1]...); err != nil {
|
||||
t.Errorf("%s, hop 9: %v", name, err)
|
||||
}
|
||||
if err := hop(ctx, "https://other.example.com/", chain...); err == nil || err.Error() != "stopped after 10 redirects" {
|
||||
t.Errorf("%s, hop 10: %v, want net/http's limit", name, err)
|
||||
}
|
||||
|
||||
// Through a real client: a hop to another port, which the gate would ask
|
||||
// for, is followed.
|
||||
final, finalHits, _ := hopServer(t, "")
|
||||
start, _, _ := hopServer(t, final.URL+"/final")
|
||||
if resp, err := gatedFetch(ctx, start.URL+"/start"); err != nil || resp.StatusCode != http.StatusOK || finalHits.Load() != 1 {
|
||||
t.Errorf("%s: chain not followed: %v, hits=%d", name, err, finalHits.Load())
|
||||
}
|
||||
loop := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
http.Redirect(w, r, r.URL.Path+"x", http.StatusFound)
|
||||
}))
|
||||
_, gotErr := gatedFetch(ctx, loop.URL+"/l")
|
||||
_, wantErr := (&http.Client{}).Get(loop.URL + "/l")
|
||||
loop.Close()
|
||||
if gotErr == nil || wantErr == nil || gotErr.Error() != wantErr.Error() {
|
||||
t.Errorf("%s: redirect loop: %v, want the default policy's %v", name, gotErr, wantErr)
|
||||
}
|
||||
}
|
||||
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())
|
||||
}
|
||||
// Gate on, the limit holds as well, before anything is read.
|
||||
if err := hop(g.ctx(), "https://app.example.com/", chain...); err == nil || err.Error() != "stopped after 10 redirects" || g.store.reads != 0 {
|
||||
t.Errorf("gate on, hop 10: %v, %d store reads", err, g.store.reads)
|
||||
}
|
||||
}
|
||||
|
||||
// A hop to a pinned target is followed, through a real client: the redirected
|
||||
// request keeps the context of the call, so the policy finds the scope. The
|
||||
// first use of the pin is logged with the origin of the redirect.
|
||||
func TestRedirectToPinnedHostFollowed(t *testing.T) {
|
||||
g := newGate(t)
|
||||
final, finalHits, finalKey := hopServer(t, "")
|
||||
start, startHits, startKey := hopServer(t, final.URL+"/final")
|
||||
host, port, _ := net.SplitHostPort(finalKey)
|
||||
pin := &db.TargetPin{ID: 1, Host: host, Port: atoiPort(t, port), Version: 1, Status: db.TargetPinActive, PinnedIPs: []string{host}}
|
||||
g.store.pins = []*db.TargetPin{pin}
|
||||
|
||||
resp, err := gatedFetch(g.ctx(), start.URL+"/start")
|
||||
if err != nil || resp.StatusCode != http.StatusOK || startHits.Load() != 1 || finalHits.Load() != 1 {
|
||||
t.Fatalf("pinned hop: %v, hits start=%d final=%d", err, startHits.Load(), finalHits.Load())
|
||||
}
|
||||
if got := g.store.outcomes(); len(got) != 1 || got[0] != "allowed/pin_match" {
|
||||
t.Fatalf("rows = %v, want the first use of the pin", got)
|
||||
}
|
||||
row := g.store.logged[0]
|
||||
if tgt := row.Audit.Targets[0]; tgt.Host != host || tgt.RedirectFrom != startKey || tgt.PinID != pin.ID {
|
||||
t.Fatalf("logged target = %+v, want %s from %s", tgt, finalKey, startKey)
|
||||
}
|
||||
if !strings.Contains(string(row.ToolInput), final.URL+"/final") || !strings.Contains(string(row.ToolInput), start.URL+"/start") {
|
||||
t.Fatalf("logged input = %s, want the hop and its origin", row.ToolInput)
|
||||
}
|
||||
if g.res.total() != 0 {
|
||||
t.Fatalf("%d lookups for an IP literal", g.res.total())
|
||||
}
|
||||
}
|
||||
|
||||
// A (host, 0) pin covers every port: http -> https on that host is followed.
|
||||
func TestRedirectToPortZeroPinnedHostFollowed(t *testing.T) {
|
||||
g := newGate(t)
|
||||
g.res.set("app.example.com", "203.0.113.7")
|
||||
g.store.pins = []*db.TargetPin{{ID: 1, Host: "app.example.com", Port: 0, Version: 1, Status: db.TargetPinActive, PinnedIPs: []string{"203.0.113.7"}}}
|
||||
if err := hop(g.ctx(), "https://app.example.com/login", "http://app.example.com/login"); err != nil {
|
||||
t.Fatalf("http -> https under a (host, 0) pin: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// A block rule stops a hop before anything is resolved, whether it names the
|
||||
// hop's host or its URL, and even when the hop stays on a pinned host. No
|
||||
// request is opened.
|
||||
func TestRedirectToBlockedNameNotResolved(t *testing.T) {
|
||||
g := newGate(t)
|
||||
g.res.set("app.example.com", "203.0.113.7")
|
||||
g.res.set("evil.example", "198.51.100.9")
|
||||
g.store.pins = []*db.TargetPin{{ID: 1, Host: "app.example.com", Port: 443, Version: 1, Status: db.TargetPinActive, PinnedIPs: []string{"203.0.113.7"}}}
|
||||
g.store.rules = []db.AssetInterceptRule{
|
||||
{Enabled: true, Kind: "exact_domain", Pattern: "evil.example"},
|
||||
{Enabled: true, Kind: "exact_url", Pattern: "https://app.example.com/admin"},
|
||||
}
|
||||
|
||||
err := hop(g.ctx(), "http://evil.example/x", "https://app.example.com/")
|
||||
if err == nil || !strings.Contains(err.Error(), TargetRuleBlocked) || !strings.Contains(err.Error(), "https://app.example.com/ redirects to evil.example:80") {
|
||||
t.Fatalf("hop to a blocked name: %v", err)
|
||||
}
|
||||
if n := g.res.lookups("evil.example"); n != 0 {
|
||||
t.Fatalf("the blocked name was resolved %d times", n)
|
||||
}
|
||||
err = hop(g.ctx(), "https://app.example.com/admin", "https://app.example.com/")
|
||||
if err == nil || !strings.Contains(err.Error(), TargetRuleBlocked) {
|
||||
t.Fatalf("hop to a blocked URL on a pinned host: %v", err)
|
||||
}
|
||||
if got := g.store.outcomes(); len(got) != 2 || got[0] != "denied/rule_blocked" || got[1] != "denied/rule_blocked" {
|
||||
t.Fatalf("rows = %v, want two blocks", got)
|
||||
}
|
||||
if from := g.store.logged[0].Audit.Targets[0].RedirectFrom; from != "app.example.com:443" {
|
||||
t.Fatalf("redirect_from = %q", from)
|
||||
}
|
||||
if g.res.total() != 0 {
|
||||
t.Fatalf("%d lookups, want none", g.res.total())
|
||||
}
|
||||
}
|
||||
|
||||
// A hop whose target cannot be read is blocked with TARGET_GATE_UNAVAILABLE, as
|
||||
// a rendered URL is, and opens no request.
|
||||
func TestRedirectUnreadableHopBlocked(t *testing.T) {
|
||||
g := newGate(t)
|
||||
for _, to := range []*url.URL{
|
||||
{Scheme: "http", Path: "/no-host"},
|
||||
{Scheme: "http", Host: "app.example.com:99999", Path: "/"},
|
||||
} {
|
||||
req, _ := http.NewRequestWithContext(g.ctx(), http.MethodGet, "http://placeholder.example/", nil)
|
||||
req.URL = to
|
||||
via, _ := http.NewRequestWithContext(g.ctx(), http.MethodGet, "https://app.example.com/", nil)
|
||||
err := RedirectCheck("WebFetch")(req, []*http.Request{via})
|
||||
if err == nil || !strings.Contains(err.Error(), TargetGateUnavailable) {
|
||||
t.Errorf("%s: %v, want TARGET_GATE_UNAVAILABLE", to, err)
|
||||
}
|
||||
}
|
||||
if got := g.store.outcomes(); len(got) != 2 || got[0] != "denied/gate_unavailable" {
|
||||
t.Fatalf("rows = %v", got)
|
||||
}
|
||||
if g.res.total() != 0 {
|
||||
t.Fatalf("%d lookups, want none", g.res.total())
|
||||
}
|
||||
}
|
||||
|
||||
func atoiPort(t *testing.T, s string) int {
|
||||
t.Helper()
|
||||
p := validPort(s)
|
||||
if p == 0 {
|
||||
t.Fatalf("port %q", s)
|
||||
}
|
||||
return p
|
||||
}
|
||||
@@ -41,6 +41,9 @@ type TargetRequest struct {
|
||||
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
|
||||
// RedirectFrom is the origin (host:port) of the redirect that leads to the
|
||||
// target; empty when the call names the target itself.
|
||||
RedirectFrom string
|
||||
}
|
||||
|
||||
// TargetResult is the outcome of RequestTargets. Code is empty when every
|
||||
@@ -248,6 +251,7 @@ func (i *Interceptor) openTargetRequest(ctx context.Context, ref db.TargetScopeR
|
||||
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,
|
||||
RedirectFrom: t.RedirectFrom,
|
||||
}
|
||||
if t.Pin != nil {
|
||||
target.PinID, target.PinVersion, target.PinnedIPs = t.Pin.ID, t.Pin.Version, t.Pin.PinnedIPs
|
||||
@@ -363,6 +367,9 @@ func targetReason(scope TargetScope, t TargetRequest, ask *Decision, nameOnly bo
|
||||
b.WriteString(ask.Message)
|
||||
b.WriteString("\nThis call also names a target that needs approval. ")
|
||||
}
|
||||
if t.RedirectFrom != "" {
|
||||
fmt.Fprintf(&b, "A redirect from %s leads to this target; the redirect was not followed. ", t.RedirectFrom)
|
||||
}
|
||||
name, bound := targetName(t), scope.scopeName()
|
||||
if t.Port == 0 {
|
||||
name += " (port not declared: every port)"
|
||||
|
||||
+29
-12
@@ -620,9 +620,14 @@ func (s *Server) StartTriggeredRun(agentKey, title, message string, taskID int64
|
||||
return
|
||||
}
|
||||
cfg := s.readTriggerBehavior(agentKey) // DB read BEFORE the lock (never under queueMu)
|
||||
// A conversation triggered by a gated task is gated (the flag only, never pins).
|
||||
gated := false
|
||||
if t, ok := s.m.Task(strconv.FormatInt(taskID, 10)); ok && taskID > 0 {
|
||||
gated = t.targetGate.Load()
|
||||
}
|
||||
s.queueMu.Lock()
|
||||
s.triggerCfg[agentKey] = cfg
|
||||
s.triggerQ[agentKey] = append(s.triggerQ[agentKey], triggeredRun{agentKey: agentKey, title: title, message: message, taskID: taskID, taskDesc: taskDesc, taskGoal: taskGoal, mergeable: mergeable})
|
||||
s.triggerQ[agentKey] = append(s.triggerQ[agentKey], triggeredRun{agentKey: agentKey, title: title, message: message, taskID: taskID, taskDesc: taskDesc, taskGoal: taskGoal, mergeable: mergeable, targetGate: gated})
|
||||
s.pumpLocked(agentKey)
|
||||
s.queueMu.Unlock()
|
||||
}
|
||||
@@ -749,15 +754,26 @@ func mergeTriggeredRuns(items []triggeredRun) triggeredRun {
|
||||
fmt.Fprintf(&b, "\n── trigger %d ──\n%s\n", i+1, it.message)
|
||||
}
|
||||
return triggeredRun{
|
||||
agentKey: first.agentKey,
|
||||
title: fmt.Sprintf("Merged triggers · task#%d · %d items", first.taskID, len(items)),
|
||||
message: b.String(),
|
||||
taskID: first.taskID,
|
||||
mergeable: true,
|
||||
agentKey: first.agentKey,
|
||||
title: fmt.Sprintf("Merged triggers · task#%d · %d items", first.taskID, len(items)),
|
||||
message: b.String(),
|
||||
taskID: first.taskID,
|
||||
mergeable: true,
|
||||
targetGate: anyTriggerGated(items),
|
||||
// taskDesc/taskGoal left empty: header already embedded above.
|
||||
}
|
||||
}
|
||||
|
||||
// anyTriggerGated reports whether any fire of a merged run came from a gated task.
|
||||
func anyTriggerGated(items []triggeredRun) bool {
|
||||
for _, it := range items {
|
||||
if it.targetGate {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// mergeAllRuns folds the ENTIRE queued burst into one run (merge_mode='all'),
|
||||
// regardless of type. Events are grouped by task (tasks in first-appearance order,
|
||||
// events in their original order within a task), so each task's context header — and
|
||||
@@ -791,11 +807,12 @@ func mergeAllRuns(items []triggeredRun) triggeredRun {
|
||||
}
|
||||
}
|
||||
return triggeredRun{
|
||||
agentKey: first.agentKey,
|
||||
title: fmt.Sprintf("Merged triggers · all · %d items", len(items)),
|
||||
message: b.String(),
|
||||
taskID: first.taskID,
|
||||
mergeable: true,
|
||||
agentKey: first.agentKey,
|
||||
title: fmt.Sprintf("Merged triggers · all · %d items", len(items)),
|
||||
message: b.String(),
|
||||
taskID: first.taskID,
|
||||
mergeable: true,
|
||||
targetGate: anyTriggerGated(items),
|
||||
// taskDesc/taskGoal left empty: per-task headers already embedded above.
|
||||
}
|
||||
}
|
||||
@@ -810,7 +827,7 @@ func (s *Server) runTriggeredRun(item triggeredRun) {
|
||||
}
|
||||
}()
|
||||
pg := s.m.pg
|
||||
c, err := pg.CreateConversation(item.agentKey, firstLine(item.title, 60), nil)
|
||||
c, err := pg.CreateConversationGated(item.agentKey, firstLine(item.title, 60), nil, item.targetGate)
|
||||
if err != nil {
|
||||
log.Printf("[trigger] create conversation for %s failed: %v", item.agentKey, err)
|
||||
return
|
||||
|
||||
@@ -455,6 +455,8 @@ func (s *Server) runHTTPTool(ctx context.Context, key string, execRaw json.RawMe
|
||||
req.Header.Set(k, renderTemplate(v, params, identity))
|
||||
}
|
||||
client := &http.Client{Timeout: timeoutOr(spec.TimeoutMs, 30000)}
|
||||
// Every redirect hop goes through the target gate, as the rendered URL did.
|
||||
client.CheckRedirect = intercept.RedirectCheck(key)
|
||||
if tr := s.httpProxyTransport(spec); tr != nil {
|
||||
client.Transport = tr
|
||||
}
|
||||
|
||||
+23
-1
@@ -13,6 +13,7 @@ import (
|
||||
|
||||
"github.com/Autumn-27/artex/agent"
|
||||
"github.com/Autumn-27/artex/db"
|
||||
"github.com/Autumn-27/artex/intercept"
|
||||
"github.com/Autumn-27/norma/permission"
|
||||
actool "github.com/Autumn-27/norma/tool"
|
||||
)
|
||||
@@ -253,7 +254,7 @@ func (s *Server) toolSpawnTask() actool.CoreTool {
|
||||
"plan_heartbeat_seconds": map[string]any{"type": "integer", "description": "optional: planner heartbeat interval (seconds). When this much time has passed since the last planning round ended/the task started with no trigger in between → run one planning round (deadlock backstop + wakes the planner to supervise in-flight workers). Empty or 0 = default 600 (10min);"},
|
||||
"seed_first_intent": map[string]any{"type": "boolean", "description": "optional: enable for simple tasks to issue one seed intent at creation (content = description + goal) so a worker can start testing right away without waiting for the first planner round; default false (the standard plan-then-execute flow)."},
|
||||
}, "description", "goal"),
|
||||
func(_ context.Context, in json.RawMessage) (actool.Result, error) {
|
||||
func(ctx context.Context, in json.RawMessage) (actool.Result, error) {
|
||||
var a struct {
|
||||
Description string `json:"description"`
|
||||
Goal string `json:"goal"`
|
||||
@@ -307,11 +308,32 @@ func (s *Server) toolSpawnTask() actool.CoreTool {
|
||||
if pin != nil {
|
||||
llmIDs = []int64{*pin}
|
||||
}
|
||||
// Target gate: a child spawned from a gated scope is gated too, and keeps
|
||||
// the block rules of the calling task. Read from the caller's scope, never
|
||||
// from parent_ref (optional and written by the model). Pins and allow rules
|
||||
// are never copied. Gate-off caller or no scope: created as before.
|
||||
var gate *bool
|
||||
var blockRules []db.TaskInterceptRuleInput
|
||||
if scope, ok := intercept.TargetScopeFromContext(ctx); ok && scope.Enabled != nil && scope.Enabled() {
|
||||
on := true
|
||||
gate = &on
|
||||
if callerID, err := strconv.ParseInt(scope.TaskID, 10, 64); err == nil && callerID > 0 {
|
||||
block, _, err := s.m.Assets().TaskInterceptRulesSplit(callerID)
|
||||
if err != nil {
|
||||
return actool.Errorf("read the block rules of the calling task: " + err.Error()), nil
|
||||
}
|
||||
for _, r := range block {
|
||||
blockRules = append(blockRules, db.TaskInterceptRuleInput{Enabled: r.Enabled, Action: r.Action, Kind: r.Kind, Pattern: r.Pattern, Note: r.Note})
|
||||
}
|
||||
}
|
||||
}
|
||||
t, err := s.m.CreateTaskWithOptions(a.Description, a.Goal, db.TaskCreateOptions{
|
||||
SourceTaskIDs: sourceIDs,
|
||||
LLMProfileIDs: llmIDs,
|
||||
TimeoutSeconds: a.TimeoutSeconds,
|
||||
PlanHeartbeatSeconds: a.PlanHeartbeatSeconds,
|
||||
InterceptRules: blockRules,
|
||||
TargetGate: gate,
|
||||
})
|
||||
if err != nil {
|
||||
return actool.Errorf(err.Error()), nil
|
||||
|
||||
@@ -147,6 +147,9 @@ type triggeredRun struct {
|
||||
taskDesc string // task description (task-level, same across one task); rendered once when merged
|
||||
taskGoal string // task goal (task-level, same across one task); rendered once when merged
|
||||
mergeable bool // true for finding/goal event triggers (merge by taskID)
|
||||
// targetGate: the source task had the target gate on when the event fired; a
|
||||
// merged run is gated if any of its fires is. Never carries pins.
|
||||
targetGate bool
|
||||
}
|
||||
|
||||
func New(ctx context.Context, m *Manager, skillDir string, dataDir string, keyDir string, opts ...Option) *Server {
|
||||
|
||||
+246
-21
@@ -5,16 +5,19 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"iter"
|
||||
"net/http/httptest"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"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"
|
||||
)
|
||||
|
||||
// Target-gate opt-in flag, pins and decisions over the real mux and the real
|
||||
@@ -180,39 +183,261 @@ func TestCreateTaskAcceptsTargetGate(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
// ruleLines renders a task's intercept rules for comparison.
|
||||
func ruleLines(t *testing.T, as *db.AssetStore, taskID string) []string {
|
||||
t.Helper()
|
||||
n, _ := strconv.ParseInt(taskID, 10, 64)
|
||||
rules, err := as.ListTaskInterceptRules(n)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
childID, found := strings.CutPrefix(res.Flatten(), "task created: ")
|
||||
if !found {
|
||||
t.Fatalf("spawn_task result: %q", res.Flatten())
|
||||
var out []string
|
||||
for _, r := range rules {
|
||||
out = append(out, fmt.Sprintf("%s %s %s enabled=%v %q", r.Action, r.Kind, r.Pattern, r.Enabled, r.Note))
|
||||
}
|
||||
g.cleanupTask(t, childID)
|
||||
return out
|
||||
}
|
||||
|
||||
// checkSpawnedChild checks the target-gate state of a spawned task: the flag,
|
||||
// live and stored, no pin, and its own intercept rules.
|
||||
func (g *gateServer) checkSpawnedChild(t *testing.T, childID string, gate bool, rules []string) {
|
||||
t.Helper()
|
||||
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 !ok {
|
||||
t.Fatalf("spawned task %s not found", childID)
|
||||
}
|
||||
if child.targetScope(0).Enabled() {
|
||||
t.Fatal("the spawned task inherited the gate")
|
||||
if child.targetScope(0).Enabled() != gate {
|
||||
t.Fatalf("spawned task gate = %v, want %v", !gate, 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 stored, err := g.m.pg.GetTask(n); err != nil || stored == nil || stored.TargetGate != gate {
|
||||
t.Fatalf("stored child = %+v, %v; want the gate %v", stored, err, gate)
|
||||
}
|
||||
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)
|
||||
}
|
||||
if got := ruleLines(t, g.m.Assets(), childID); !slices.Equal(got, rules) {
|
||||
t.Fatalf("spawned task rules = %q, want %q", got, rules)
|
||||
}
|
||||
}
|
||||
|
||||
// A task spawned through spawn_task takes the target gate of the calling scope
|
||||
// (D23) and, from a gated task, that task's block rules (D24). It never takes
|
||||
// pins or allow rules, and parent_ref plays no part. A gate-off caller and a
|
||||
// call with no scope create the child as before: gate off, no rules.
|
||||
func TestSpawnedTaskInheritsGateFromCaller(t *testing.T) {
|
||||
g := newGateServer(t)
|
||||
on := true
|
||||
parentRules := []db.TaskInterceptRuleInput{
|
||||
{Enabled: true, Action: "block", Kind: "exact_domain", Pattern: "blocked.example.com", Note: "out of scope"},
|
||||
{Enabled: false, Action: "block", Kind: "cidr", Pattern: "198.51.100.0/24"},
|
||||
{Enabled: true, Action: "allow", Kind: "exact_domain", Pattern: "allowed.example.com"},
|
||||
}
|
||||
gated := g.newTask(t, db.TaskCreateOptions{TargetGate: &on, InterceptRules: parentRules})
|
||||
open := g.newTask(t, db.TaskCreateOptions{InterceptRules: parentRules})
|
||||
if !gated.targetScope(0).Enabled() || open.targetScope(0).Enabled() {
|
||||
t.Fatal("control: one parent gated, the other not")
|
||||
}
|
||||
// The gated parent holds one pin, which no child may get.
|
||||
if p, ok, err := g.m.pg.ResolveTargetRequest(openTargetRow(t, g.m.pg, taskRef(t, gated), "app.example.com", "203.0.113.7"), true, "ARTEX", "192.0.2.1"); err != nil || !ok || p == nil {
|
||||
t.Fatalf("pin the parent: %+v ok=%v err=%v", p, ok, err)
|
||||
}
|
||||
conv, err := g.m.pg.CreateConversationGated("auto", "spawn from a gated chat", nil, true)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = g.m.pg.DeleteConversation(conv.ID) })
|
||||
gatedChat := intercept.TargetScope{ConversationID: conv.ID, Enabled: func() bool { return true },
|
||||
WaitBound: intercept.DefaultTargetWaitBound, Interceptor: g.m.interceptor}
|
||||
scoped := func(s intercept.TargetScope) context.Context {
|
||||
return intercept.WithTargetScope(context.Background(), s)
|
||||
}
|
||||
blockOnly := []string{
|
||||
`block exact_domain blocked.example.com enabled=true "out of scope"`,
|
||||
`block cidr 198.51.100.0/24 enabled=false ""`,
|
||||
}
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
ctx context.Context
|
||||
parentRef string
|
||||
gate bool
|
||||
rules []string
|
||||
}{
|
||||
{"gated task", scoped(gated.targetScope(intercept.DefaultTargetWaitBound)), "", true, blockOnly},
|
||||
{"gated task, parent_ref names a gate-off task", scoped(gated.targetScope(0)), open.ID, true, blockOnly},
|
||||
{"gated conversation", scoped(gatedChat), "", true, nil},
|
||||
{"gate-off task", scoped(open.targetScope(0)), "", false, nil},
|
||||
{"gate-off task, parent_ref names a gated task", scoped(open.targetScope(0)), gated.ID, false, nil},
|
||||
{"no scope, parent_ref names a gated task", context.Background(), gated.ID, false, nil},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
args := map[string]any{"description": "spawned child", "goal": gateTestGoal}
|
||||
if tc.parentRef != "" {
|
||||
args["parent_ref"] = tc.parentRef
|
||||
}
|
||||
in, _ := json.Marshal(args)
|
||||
res, err := g.s.toolSpawnTask().Call(tc.ctx, 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)
|
||||
if child, _ := g.m.Task(childID); child == nil || child.ParentRef != tc.parentRef {
|
||||
t.Fatalf("spawned task = %+v, want parent_ref %q", child, tc.parentRef)
|
||||
}
|
||||
g.checkSpawnedChild(t, childID, tc.gate, tc.rules)
|
||||
})
|
||||
}
|
||||
// The callers are unchanged.
|
||||
if pins, err := g.m.pg.ListTargetPins(taskRef(t, gated)); err != nil || len(pins) != 1 {
|
||||
t.Fatalf("parent pins = %+v, %v; want still one", pins, err)
|
||||
}
|
||||
if got := ruleLines(t, g.m.Assets(), gated.ID); len(got) != 3 {
|
||||
t.Fatalf("parent rules = %q, want the three it was created with", got)
|
||||
}
|
||||
}
|
||||
|
||||
// toolCallProvider is a model that makes one tool call on its first turn and
|
||||
// ends the run on the next.
|
||||
type toolCallProvider struct {
|
||||
tool, input string
|
||||
turn atomic.Int64
|
||||
}
|
||||
|
||||
func (p *toolCallProvider) Stream(context.Context, llm.CompletionRequest) iter.Seq2[llm.StreamEvent, error] {
|
||||
events := []llm.StreamEvent{{Type: llm.SETextDelta, Text: "done"}, {Type: llm.SEMessageDelta, StopReason: "end_turn"}}
|
||||
if p.turn.Add(1) == 1 {
|
||||
events = []llm.StreamEvent{
|
||||
{Type: llm.SEToolUseStart, ToolID: "call-1", ToolName: p.tool},
|
||||
{Type: llm.SEToolInputJSON, Text: p.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 *toolCallProvider) Complete(ctx context.Context, req llm.CompletionRequest) (llm.Message, string, llm.Usage, error) {
|
||||
return accumulateStreamForTest(ctx, p.Stream, req)
|
||||
}
|
||||
|
||||
// spawn_task called by the model of an auto conversation, through the real
|
||||
// harness, tool binding and tool context: the child takes the conversation's
|
||||
// flag. The unit test above builds the caller's context by hand; this one shows
|
||||
// the conversation's scope reaches the tool.
|
||||
func TestSpawnTaskSeesCallerScopeThroughHarness(t *testing.T) {
|
||||
g := newGateServer(t)
|
||||
for _, gate := range []bool{true, false} {
|
||||
t.Run(fmt.Sprintf("conversation gate %v", gate), func(t *testing.T) {
|
||||
desc := fmt.Sprintf("harness child %v %d", gate, time.Now().UnixNano())
|
||||
in, _ := json.Marshal(map[string]any{"description": desc, "goal": gateTestGoal})
|
||||
conv, err := g.m.pg.CreateConversationGated("auto", "spawn through the harness", nil, gate)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = g.m.pg.DeleteConversation(conv.ID) })
|
||||
g.s.cfgMu.Lock()
|
||||
g.s.chatAgent = agent.NewChatAgent(&toolCallProvider{tool: "spawn_task", input: string(in)}, "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(), conv.ID))
|
||||
g.s.runConversationTurn(ctx, cancel, conv, "spawn the child task", g.s.convBusyKey(conv.ID))
|
||||
var childID string
|
||||
for _, task := range g.m.List() {
|
||||
if task.Description == desc {
|
||||
childID = task.ID
|
||||
}
|
||||
}
|
||||
if childID == "" {
|
||||
t.Fatal("the model's spawn_task call created no task")
|
||||
}
|
||||
g.cleanupTask(t, childID)
|
||||
g.checkSpawnedChild(t, childID, gate, nil)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// A conversation started by a trigger of a gated task is gated (D25): a single
|
||||
// fire, a by-task merge, and a merge across tasks of which one is gated. A fire
|
||||
// of a gate-off task, or with no task, starts it gate off as before. The flag is
|
||||
// copied, never pins.
|
||||
func TestTriggeredConversationInheritsGate(t *testing.T) {
|
||||
g := newGateServer(t)
|
||||
g.s.cfgMu.Lock()
|
||||
g.s.chatAgent = agent.NewChatAgent(newScopeProbeProvider(), "test-model", g.m.dir, nil, 10000)
|
||||
g.s.cfgMu.Unlock()
|
||||
on := true
|
||||
gated, open := g.newTask(t, db.TaskCreateOptions{TargetGate: &on}), g.newTask(t, db.TaskCreateOptions{})
|
||||
if p, ok, err := g.m.pg.ResolveTargetRequest(openTargetRow(t, g.m.pg, taskRef(t, gated), "app.example.com", "203.0.113.7"), true, "ARTEX", "192.0.2.1"); err != nil || !ok || p == nil {
|
||||
t.Fatalf("pin the gated task: %+v ok=%v err=%v", p, ok, err)
|
||||
}
|
||||
gatedID, openID := taskRef(t, gated).TaskID, taskRef(t, open).TaskID
|
||||
|
||||
// The server context is cancelled, so StartTriggeredRun only queues. The test
|
||||
// takes the next run with the merge mode under test and runs it as the pump would.
|
||||
trigger := func(t *testing.T, mode string, taskIDs ...int64) *db.Conversation {
|
||||
t.Helper()
|
||||
key := fmt.Sprintf("gate-trigger-%d", time.Now().UnixNano())
|
||||
for _, id := range taskIDs {
|
||||
g.s.StartTriggeredRun(key, "trigger", "event body", id, id > 0, "desc", "goal")
|
||||
}
|
||||
g.s.queueMu.Lock()
|
||||
item := g.s.nextTriggerRun(key, triggerBehavior{runMode: "serial", mergeMode: mode})
|
||||
left := len(g.s.triggerQ[key])
|
||||
delete(g.s.triggerQ, key)
|
||||
delete(g.s.triggerCfg, key)
|
||||
g.s.queueMu.Unlock()
|
||||
if left != 0 {
|
||||
t.Fatalf("%d fires left in the queue, want one run", left)
|
||||
}
|
||||
item.title = key
|
||||
g.s.runTriggeredRun(item)
|
||||
convs, err := g.m.pg.ListConversations()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, c := range convs {
|
||||
if c.Title == key {
|
||||
t.Cleanup(func() { _ = g.m.pg.DeleteConversation(c.ID) })
|
||||
return c
|
||||
}
|
||||
}
|
||||
t.Fatal("the trigger created no conversation")
|
||||
return nil
|
||||
}
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
mode string
|
||||
tasks []int64
|
||||
gate bool
|
||||
}{
|
||||
{"gated task", "none", []int64{gatedID}, true},
|
||||
{"gated task, merged by task", "by_task", []int64{gatedID, gatedID}, true},
|
||||
{"merge across tasks, gated second", "all", []int64{openID, gatedID}, true},
|
||||
{"gate-off task", "none", []int64{openID}, false},
|
||||
{"gate-off task, merged by task", "by_task", []int64{openID, openID}, false},
|
||||
{"no task", "none", []int64{0}, false},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
c := trigger(t, tc.mode, tc.tasks...)
|
||||
if c.TargetGate != tc.gate {
|
||||
t.Fatalf("triggered conversation gate = %v, want %v", c.TargetGate, tc.gate)
|
||||
}
|
||||
if pins, err := g.m.pg.ListTargetPins(db.TargetScopeRef{ConversationID: c.ID}); err != nil || len(pins) != 0 {
|
||||
t.Fatalf("the triggered conversation has pins: %+v, %v", pins, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// The conversation flag is set on create and on patch, and the turn that starts
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Autumn-27/artex/db"
|
||||
)
|
||||
|
||||
// Step 1.9: the redirect hops of the custom http tool and of a worker's
|
||||
// WebFetch go through the target gate.
|
||||
|
||||
// redirectingServer answers every request with a redirect to location and
|
||||
// counts the requests. Its host:port is an IP literal: the gate never resolves it.
|
||||
func redirectingServer(t *testing.T, location string) (srv *httptest.Server, hits *atomic.Int64, key string) {
|
||||
t.Helper()
|
||||
hits = &atomic.Int64{}
|
||||
srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
hits.Add(1)
|
||||
http.Redirect(w, r, location, http.StatusFound)
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
return srv, hits, srv.Listener.Addr().String()
|
||||
}
|
||||
|
||||
func decide(t *testing.T, g *gateServer, id int64, decision string) {
|
||||
t.Helper()
|
||||
if code, body := g.do(t, "POST", fmt.Sprintf("/api/intercept/pending/%d/decide", id), `{"decision":"`+decision+`"}`); code != 200 {
|
||||
t.Fatalf("decide %d: %d %v", id, code, body)
|
||||
}
|
||||
}
|
||||
|
||||
// A custom http tool whose rendered URL is pinned, and which redirects to a
|
||||
// target no pin covers: the hop is not followed, one request is opened for its
|
||||
// target, and the call fails at once. After the approval the same call follows.
|
||||
func TestCustomHTTPRedirectTargetGated(t *testing.T) {
|
||||
g := newGateServer(t)
|
||||
on := true
|
||||
task := g.newTask(t, db.TaskCreateOptions{TargetGate: &on})
|
||||
pg := g.m.pg
|
||||
final, finalHits, fhost, fport := countingServer(t)
|
||||
finalKey := net.JoinHostPort(fhost, fport)
|
||||
start, startHits, startKey := redirectingServer(t, final.URL+"/final")
|
||||
|
||||
tool := g.s.buildCustomTool(&db.Tool{Key: "probe_redirect", Kind: "http",
|
||||
Exec: execJSON(t, map[string]any{"method": "GET", "url": start.URL + "/start"})})
|
||||
// The rendered URL itself first: its request, approved.
|
||||
if out, isErr := callTool(t, tool, gatedCtx(task, 0), `{}`, nil); !isErr || !strings.Contains(out, "TARGET_APPROVAL_PENDING") || startHits.Load() != 0 {
|
||||
t.Fatalf("rendered URL: error=%v out=%q", isErr, out)
|
||||
}
|
||||
id, _, _, ok := openTargetRequest(t, pg, task.ID, startKey)
|
||||
if !ok {
|
||||
t.Fatal("no request for the rendered URL")
|
||||
}
|
||||
decide(t, g, id, "allowed")
|
||||
|
||||
// The wait bound is long: the hop must not use it.
|
||||
began := time.Now()
|
||||
out, isErr := callTool(t, tool, gatedCtx(task, time.Minute), `{}`, nil)
|
||||
if !isErr || !strings.Contains(out, "TARGET_APPROVAL_PENDING") || !strings.Contains(out, start.URL+"/start redirects to "+finalKey) {
|
||||
t.Fatalf("redirect to an unpinned target: error=%v out=%q", isErr, out)
|
||||
}
|
||||
if elapsed := time.Since(began); elapsed > 10*time.Second {
|
||||
t.Fatalf("the hop waited %v", elapsed)
|
||||
}
|
||||
if startHits.Load() != 1 || finalHits.Load() != 0 {
|
||||
t.Fatalf("hits origin=%d target=%d, want the target untouched", startHits.Load(), finalHits.Load())
|
||||
}
|
||||
hopID, toolName, toolInput, ok := openTargetRequest(t, pg, task.ID, finalKey)
|
||||
if !ok || toolName != "probe_redirect" || !strings.Contains(toolInput, final.URL+"/final") || !strings.Contains(toolInput, start.URL+"/start") {
|
||||
t.Fatalf("request for the hop: ok=%v tool=%q input=%s", ok, toolName, toolInput)
|
||||
}
|
||||
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 != 2 {
|
||||
t.Fatalf("%d requests, %v; want the origin's and the hop's", requests, err)
|
||||
}
|
||||
|
||||
decide(t, g, hopID, "allowed")
|
||||
if out, isErr := callTool(t, tool, gatedCtx(task, 0), `{}`, nil); isErr || !strings.Contains(out, "probe-reached") || finalHits.Load() != 1 {
|
||||
t.Fatalf("after the approval: error=%v out=%q target hits=%d", isErr, out, finalHits.Load())
|
||||
}
|
||||
}
|
||||
|
||||
// The custom-tool test endpoint has no approval scope (D14): its redirects are
|
||||
// followed as before, and nothing is written.
|
||||
func TestCustomToolTestEndpointRedirectUngated(t *testing.T) {
|
||||
g := newGateServer(t)
|
||||
on := true
|
||||
g.newTask(t, db.TaskCreateOptions{TargetGate: &on})
|
||||
final, finalHits, _, _ := countingServer(t)
|
||||
start, _, _ := redirectingServer(t, final.URL+"/final")
|
||||
count := func() int {
|
||||
var n int
|
||||
if err := g.m.pg.QueryRow(`SELECT count(*) FROM intercept_pending WHERE decision_source='target' OR target_key IS NOT NULL`).Scan(&n); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return n
|
||||
}
|
||||
before := count()
|
||||
code, out := g.do(t, "POST", "/api/tools/custom/test", fmt.Sprintf(`{"kind":"http","exec":{"url":%q}}`, start.URL+"/start"))
|
||||
if code != 200 || out["is_error"] != false || !strings.Contains(fmt.Sprint(out["output"]), "probe-reached") || finalHits.Load() != 1 {
|
||||
t.Fatalf("test endpoint: %d %v target hits=%d", code, out, finalHits.Load())
|
||||
}
|
||||
if after := count(); after != before {
|
||||
t.Fatalf("the test endpoint wrote %d target rows", after-before)
|
||||
}
|
||||
}
|
||||
|
||||
// A gated worker's WebFetch end to end: the origin is approved at the hook, the
|
||||
// redirect stops at the hop with TARGET_APPROVAL_PENDING and leaves one request
|
||||
// open; once it is approved through the API, the same call follows.
|
||||
func TestRedirectRequestSurvivesAndRetryPasses(t *testing.T) {
|
||||
g := newGateServer(t)
|
||||
on := true
|
||||
task := g.newTask(t, db.TaskCreateOptions{TargetGate: &on})
|
||||
pg := g.m.pg
|
||||
final, finalHits, fhost, fport := countingServer(t)
|
||||
finalKey := net.JoinHostPort(fhost, fport)
|
||||
start, startHits, startKey := redirectingServer(t, final.URL+"/final")
|
||||
p := &toolScript{calls: fetches(start.URL+"/start", start.URL+"/start"), results: make(chan string, 16),
|
||||
hold: map[int64]chan struct{}{2: make(chan struct{})}}
|
||||
done := runScriptedWorker(t, g, task, p)
|
||||
|
||||
var id int64
|
||||
waitUntil(t, "the request for the origin", func() bool {
|
||||
var ok bool
|
||||
id, _, _, ok = openTargetRequest(t, pg, task.ID, startKey)
|
||||
return ok
|
||||
})
|
||||
decide(t, g, id, "allowed")
|
||||
if r := p.next(t); !strings.Contains(r, "TARGET_APPROVAL_PENDING") || !strings.Contains(r, "redirects to "+finalKey) {
|
||||
t.Fatalf("first call: %q", r)
|
||||
}
|
||||
if startHits.Load() != 1 || finalHits.Load() != 0 {
|
||||
t.Fatalf("hits origin=%d target=%d, want the target untouched", startHits.Load(), finalHits.Load())
|
||||
}
|
||||
hopID, toolName, _, ok := openTargetRequest(t, pg, task.ID, finalKey)
|
||||
if !ok || toolName != "WebFetch" {
|
||||
t.Fatalf("request for the hop: ok=%v tool=%q", ok, toolName)
|
||||
}
|
||||
detail, err := pg.GetInterceptDetail(hopID)
|
||||
if err != nil || detail == nil || detail.Audit == nil || len(detail.Audit.Targets) != 1 || detail.Audit.Targets[0].RedirectFrom != startKey {
|
||||
t.Fatalf("hop request detail = %+v, %v", detail, err)
|
||||
}
|
||||
|
||||
decide(t, g, hopID, "allowed")
|
||||
close(p.hold[2])
|
||||
if r := p.next(t); !strings.Contains(r, "probe-reached") || finalHits.Load() != 1 {
|
||||
t.Fatalf("the same call after the approval: %q target hits=%d", r, finalHits.Load())
|
||||
}
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(60 * time.Second):
|
||||
t.Fatal("the worker run did not end")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user