mirror of
https://github.com/BaaRai/ARTEX.git
synced 2026-11-13 19:50:54 +08:00
Compare commits
11
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b17a0ea796 | ||
|
|
59c3702a5e | ||
|
|
05df40eecb | ||
|
|
f4f0df128a | ||
|
|
00f5d8c103 | ||
|
|
a44af3ca2b | ||
|
|
81eab87d16 | ||
|
|
06453409ea | ||
|
|
c73e2872a3 | ||
|
|
a83f494d03 | ||
|
|
a66c716c0a |
@@ -17,3 +17,8 @@ ARTEX_LLM_PROVIDER=
|
||||
ARTEX_LLM_MODEL=
|
||||
ARTEX_LLM_BASE_URL=
|
||||
ARTEX_LLM_PROXY=
|
||||
|
||||
# Untrusted-output boundary (optional). ARTEX_UNTRUSTED_BOUNDARY=1 frames every tool
|
||||
# output between nonce-tagged markers so the model treats it as data, not instructions.
|
||||
# Off by default; test it against a local target before enabling it in production.
|
||||
# ARTEX_UNTRUSTED_BOUNDARY=1
|
||||
|
||||
@@ -160,6 +160,9 @@ func (c *ChatAgent) Chat(ctx context.Context, agentKey, sessionID, message strin
|
||||
Settlement: wrapupSettlement(agentKey, nil),
|
||||
NonStreaming: c.nonStreaming(), // when this profile selects non-streaming, use Provider.Complete
|
||||
MaxTokens: c.maxTokens(), // 0 = send no cap, the server's default decides
|
||||
|
||||
// Opt-in via ARTEX_UNTRUSTED_BOUNDARY (default off): nonce-frame tool output as untrusted data.
|
||||
UntrustedOutputBoundary: untrustedBoundaryEnabled(),
|
||||
}
|
||||
if c.guard != nil {
|
||||
opts.Hooks = c.guard.Hooks()
|
||||
|
||||
@@ -169,6 +169,9 @@ func (m *MainAgent) Chat(ctx context.Context, taskID int64, mainSeg int, as *db.
|
||||
Settlement: wrapupSettlement("mainagent", nil),
|
||||
NonStreaming: m.nonStreaming(), // when this profile selects non-streaming, use Provider.Complete
|
||||
MaxTokens: m.maxTokens(), // 0 = send no cap, the server's default decides
|
||||
|
||||
// Opt-in via ARTEX_UNTRUSTED_BOUNDARY (default off): nonce-frame tool output as untrusted data.
|
||||
UntrustedOutputBoundary: untrustedBoundaryEnabled(),
|
||||
}
|
||||
if m.tx != nil { // persist raw human↔AI conversation; one accumulating file per segment
|
||||
opts.Transcript = m.tx
|
||||
|
||||
@@ -457,6 +457,9 @@ func (p *Planner) Plan(ctx context.Context, taskID int64, as *db.AssetStore, ts
|
||||
Settlement: settle,
|
||||
NonStreaming: p.nonStreaming(), // when this profile selects non-streaming, use Provider.Complete
|
||||
MaxTokens: p.maxTokens(), // 0 = send no cap; the server default decides
|
||||
|
||||
// Opt-in via ARTEX_UNTRUSTED_BOUNDARY (default off): nonce-frame tool output as untrusted data.
|
||||
UntrustedOutputBoundary: untrustedBoundaryEnabled(),
|
||||
}
|
||||
if p.tx != nil { // persist raw LLM conversation; one accumulating file per task's planner
|
||||
opts.Transcript = p.tx
|
||||
|
||||
@@ -3,6 +3,7 @@ package agent
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
actool "github.com/Autumn-27/norma/tool"
|
||||
@@ -46,6 +47,46 @@ func TestBuiltinToolSeeds(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestObjDropsBlankRequired: obj never emits a blank required name, and omits the
|
||||
// "required" key entirely when no real name is left.
|
||||
func TestObjDropsBlankRequired(t *testing.T) {
|
||||
if got, ok := obj(map[string]any{}, "")["required"]; ok {
|
||||
t.Errorf(`obj(props, "") required = %v, want no required key`, got)
|
||||
}
|
||||
got, _ := obj(map[string]any{}, " ", "x")["required"].([]string)
|
||||
if len(got) != 1 || got[0] != "x" {
|
||||
t.Errorf(`obj(props, " ", "x") required = %v, want [x]`, got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestListTaskCredentialsSchemaHasNoBlankRequired guards the seeded schema of the
|
||||
// parameterless list_task_credentials tool: "required": [""] is rejected by strict
|
||||
// providers, which fails every worker run.
|
||||
func TestListTaskCredentialsSchemaHasNoBlankRequired(t *testing.T) {
|
||||
for _, s := range BuiltinToolSeeds() {
|
||||
if s.Key != "list_task_credentials" {
|
||||
continue
|
||||
}
|
||||
raw, err := json.Marshal(s.Schema)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var schema struct {
|
||||
Required []string `json:"required"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &schema); err != nil {
|
||||
t.Fatalf("schema %s: %v", raw, err)
|
||||
}
|
||||
for _, r := range schema.Required {
|
||||
if strings.TrimSpace(r) == "" {
|
||||
t.Errorf("blank required entry in schema %s", raw)
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
t.Fatal("list_task_credentials not seeded")
|
||||
}
|
||||
|
||||
// TestDecorateToolInjectsDefaults verifies a schema "default" fills a missing param
|
||||
// before the underlying handler runs, and an explicitly-provided value is kept.
|
||||
func TestDecorateToolInjectsDefaults(t *testing.T) {
|
||||
|
||||
+10
-2
@@ -351,8 +351,16 @@ func pidList(raw []json.RawMessage) []int64 {
|
||||
|
||||
func obj(props map[string]any, required ...string) map[string]any {
|
||||
m := map[string]any{"type": "object", "properties": props}
|
||||
if len(required) > 0 {
|
||||
m["required"] = required
|
||||
// Blank names are dropped, and "required" is omitted when nothing is left:
|
||||
// strict providers reject "required": [""] and fail every run carrying the tool.
|
||||
req := make([]string, 0, len(required))
|
||||
for _, r := range required {
|
||||
if strings.TrimSpace(r) != "" {
|
||||
req = append(req, r)
|
||||
}
|
||||
}
|
||||
if len(req) > 0 {
|
||||
m["required"] = req
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
@@ -64,7 +64,7 @@ func (t *ToolSet) listTaskCredentials() actool.CoreTool {
|
||||
return writeTool(
|
||||
"list_task_credentials",
|
||||
"List this task's test accounts: user-declared accounts (e.g. program-provided aliases; a user-declared entry with an empty password means: register an account on that domain with a strong password, then record it via save_task_credential) plus accounts recorded by earlier runs. Check this BEFORE registering a new account, and use matching entries for authenticated testing.",
|
||||
obj(map[string]any{}, ""),
|
||||
obj(map[string]any{}),
|
||||
func(_ context.Context, _ json.RawMessage) (actool.Result, error) {
|
||||
if t.ts == nil {
|
||||
return actool.Errorf("list_task_credentials is not enabled: task store is not initialized"), nil
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// untrustedBoundaryEnabled reports whether ARTEX_UNTRUSTED_BOUNDARY opts in to
|
||||
// Norma's untrusted-output boundary (agentcore.Options.UntrustedOutputBoundary:
|
||||
// tool results framed between nonce-tagged markers). Only "1", "true" and "on"
|
||||
// (case- and space-insensitive) enable it; unset, empty or anything else keeps
|
||||
// it off.
|
||||
func untrustedBoundaryEnabled() bool {
|
||||
switch strings.ToLower(strings.TrimSpace(os.Getenv("ARTEX_UNTRUSTED_BOUNDARY"))) {
|
||||
case "1", "true", "on":
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestUntrustedBoundaryEnabled(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
env string
|
||||
want bool
|
||||
}{
|
||||
{"", false},
|
||||
{"0", false},
|
||||
{"false", false},
|
||||
{"yes", false},
|
||||
{"1", true},
|
||||
{"true", true},
|
||||
{"on", true},
|
||||
{" TRUE ", true},
|
||||
{"On", true},
|
||||
} {
|
||||
t.Setenv("ARTEX_UNTRUSTED_BOUNDARY", tc.env)
|
||||
if got := untrustedBoundaryEnabled(); got != tc.want {
|
||||
t.Errorf("ARTEX_UNTRUSTED_BOUNDARY=%q: got %v, want %v", tc.env, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestUntrustedBoundaryDisabledWhenUnset(t *testing.T) {
|
||||
t.Setenv("ARTEX_UNTRUSTED_BOUNDARY", "1") // registers restoration of the original value
|
||||
os.Unsetenv("ARTEX_UNTRUSTED_BOUNDARY")
|
||||
if untrustedBoundaryEnabled() {
|
||||
t.Fatal("ARTEX_UNTRUSTED_BOUNDARY unset: got true, want false")
|
||||
}
|
||||
}
|
||||
|
||||
// Structural, not behavioural: the four agentcore.Options literals live inside
|
||||
// run methods that need a database and an LLM provider, so this only checks
|
||||
// that each source file passes the helper to the option exactly once.
|
||||
func TestUntrustedBoundaryWiredInOptionsLiterals(t *testing.T) {
|
||||
const want = "UntrustedOutputBoundary: untrustedBoundaryEnabled(),"
|
||||
for _, f := range []string{"worker.go", "planner.go", "chat.go", "mainagent.go"} {
|
||||
src, err := os.ReadFile(f)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
n := 0
|
||||
for _, line := range strings.Split(string(src), "\n") {
|
||||
if strings.TrimSpace(line) == want {
|
||||
n++
|
||||
}
|
||||
}
|
||||
if n != 1 {
|
||||
t.Errorf("%s: %d lines equal to %q, want 1", f, n, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -526,6 +526,9 @@ func (w *Worker) execute(ctx context.Context, name string, taskID int64, as *db.
|
||||
Todos: actool.NewTodoStore(), // session-level scratch todos (TodoWrite), pure planning, discarded on exit
|
||||
NonStreaming: w.nonStreaming(), // when this profile selects non-streaming, use Provider.Complete
|
||||
MaxTokens: w.maxTokens(), // 0 = send no cap; the server default decides
|
||||
|
||||
// Opt-in via ARTEX_UNTRUSTED_BOUNDARY (default off): nonce-frame tool output as untrusted data.
|
||||
UntrustedOutputBoundary: untrustedBoundaryEnabled(),
|
||||
}
|
||||
if hooks != nil { // typed-nil guard: only set when concrete (avoids harness panic)
|
||||
opts.Hooks = hooks
|
||||
|
||||
@@ -36,6 +36,7 @@ services:
|
||||
ARTEX_LLM_PROXY: ${ARTEX_LLM_PROXY:-}
|
||||
ARTEX_JWT_KEY_DIR: /app/keys
|
||||
ARTEX_TRUSTED_PROXIES: ${ARTEX_TRUSTED_PROXIES:-}
|
||||
ARTEX_UNTRUSTED_BOUNDARY: ${ARTEX_UNTRUSTED_BOUNDARY:-}
|
||||
ports:
|
||||
- "${ARTEX_BIND_ADDR:-127.0.0.1}:8787:8787"
|
||||
- "127.0.0.1:8788:8788"
|
||||
|
||||
+2
-2
@@ -149,7 +149,7 @@ func byteSize(s string) int { return len(s) }
|
||||
func runeSize(s string) int { return utf8.RuneCountInString(s) }
|
||||
|
||||
// assetLine renders an asset list into one line of display text; when there are
|
||||
// more than limit assets, the rest are omitted and the total is noted. A
|
||||
// more than limit assets, the rest are omitted and their count is noted. A
|
||||
// vulnerability can be anchored to dozens of assets, and listing them all would
|
||||
// blow up the message.
|
||||
func assetLine(assets []string, limit int) string {
|
||||
@@ -159,7 +159,7 @@ func assetLine(assets []string, limit int) string {
|
||||
if limit <= 0 || len(assets) <= limit {
|
||||
return strings.Join(assets, ", ")
|
||||
}
|
||||
return strings.Join(assets[:limit], ", ") + " and " + itoa(len(assets)) + " more"
|
||||
return strings.Join(assets[:limit], ", ") + " and " + itoa(len(assets)-limit) + " more"
|
||||
}
|
||||
|
||||
// itoa is a short alias for strconv.Itoa, used only for assembling display text,
|
||||
|
||||
@@ -113,11 +113,13 @@ func TestAssetLineOmitsExcess(t *testing.T) {
|
||||
if got := assetLine([]string{"a", "b"}, 3); got != "a, b" {
|
||||
t.Fatalf("within the limit everything should be listed, got %q", got)
|
||||
}
|
||||
// Over the limit the total must be noted, otherwise readers cannot tell how
|
||||
// many assets were left out.
|
||||
got := assetLine([]string{"a", "b", "c", "d", "e"}, 2)
|
||||
if !strings.Contains(got, "and 5 more") {
|
||||
t.Fatalf("the total of 5 should be noted, got %q", got)
|
||||
// Over the limit the number of omitted assets must be noted, otherwise
|
||||
// readers cannot tell how many assets were left out.
|
||||
if got := assetLine([]string{"a", "b", "c", "d", "e"}, 2); got != "a, b and 3 more" {
|
||||
t.Fatalf("5 assets with limit 2 should note the 3 omitted, got %q", got)
|
||||
}
|
||||
if got := assetLine([]string{"a", "b", "c", "d", "e", "f", "g", "h"}, 3); got != "a, b, c and 5 more" {
|
||||
t.Fatalf("8 assets with limit 3 should note the 5 omitted, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,9 @@ package server
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"compress/flate"
|
||||
"compress/gzip"
|
||||
"compress/zlib"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
@@ -40,8 +43,60 @@ func normalizeEvidenceText(s string) string {
|
||||
return strings.Join(strings.Fields(s), " ")
|
||||
}
|
||||
|
||||
// decodeEvidenceBody undoes the HTTP Content-Encoding declared in respHead on a
|
||||
// captured response body, returning at most limit decoded bytes. Only identity
|
||||
// and one layer of gzip or deflate are handled. Any other coding, a stacked
|
||||
// coding or a stream that does not decode is an error: the raw bytes are never
|
||||
// returned in place of a failed decode.
|
||||
func decodeEvidenceBody(respHead string, raw []byte, limit int) (out []byte, truncated bool, err error) {
|
||||
coding := ""
|
||||
for _, line := range strings.Split(respHead, "\n") {
|
||||
name, value, ok := strings.Cut(line, ":")
|
||||
if !ok || !strings.EqualFold(strings.TrimSpace(name), "Content-Encoding") {
|
||||
continue
|
||||
}
|
||||
for _, v := range strings.Split(value, ",") {
|
||||
switch v = strings.ToLower(strings.TrimSpace(v)); v {
|
||||
case "", "identity":
|
||||
case "gzip", "deflate":
|
||||
if coding != "" {
|
||||
return nil, false, fmt.Errorf("unsupported content-encoding: more than one compressed layer")
|
||||
}
|
||||
coding = v
|
||||
default:
|
||||
return nil, false, fmt.Errorf("unsupported content-encoding: %q", v)
|
||||
}
|
||||
}
|
||||
}
|
||||
if coding == "" || len(raw) == 0 {
|
||||
return raw, false, nil
|
||||
}
|
||||
var zr io.Reader
|
||||
switch {
|
||||
case coding == "gzip":
|
||||
zr, err = gzip.NewReader(bytes.NewReader(raw))
|
||||
case len(raw) >= 2 && raw[0]&0x0f == 8 && raw[0]>>4 <= 7 && (uint(raw[0])<<8|uint(raw[1]))%31 == 0:
|
||||
// HTTP "deflate" is the zlib format (RFC 9110); some servers send a bare
|
||||
// deflate stream instead, told apart by the zlib header.
|
||||
zr, err = zlib.NewReader(bytes.NewReader(raw))
|
||||
default:
|
||||
zr = flate.NewReader(bytes.NewReader(raw))
|
||||
}
|
||||
if err == nil {
|
||||
out, err = io.ReadAll(io.LimitReader(zr, int64(limit)+1))
|
||||
}
|
||||
if err != nil {
|
||||
return nil, false, fmt.Errorf("%s body: %w", coding, err)
|
||||
}
|
||||
if len(out) > limit {
|
||||
return out[:limit], true, nil
|
||||
}
|
||||
return out, false, nil
|
||||
}
|
||||
|
||||
// readBoundResponse reads the response side of a bound snapshot through the
|
||||
// evidence store (same bytes the UI shows), capped at verificationReadCap.
|
||||
// evidence store (same bytes the UI shows), capped at verificationReadCap, and
|
||||
// decodes its Content-Encoding under the same cap.
|
||||
func readBoundResponse(store *evidence.Store, snap db.TrafficEvidenceSnapshot) (boundBody, error) {
|
||||
f, _, err := store.OpenBody(snap, "response")
|
||||
if err != nil {
|
||||
@@ -56,6 +111,15 @@ func readBoundResponse(store *evidence.Store, snap db.TrafficEvidenceSnapshot) (
|
||||
if len(raw) > verificationReadCap {
|
||||
bb.truncated = true
|
||||
raw = raw[:verificationReadCap]
|
||||
}
|
||||
raw, cut, err := decodeEvidenceBody(snap.RespHead, raw, verificationReadCap)
|
||||
if err != nil {
|
||||
return boundBody{}, err
|
||||
}
|
||||
if cut {
|
||||
bb.truncated = true
|
||||
}
|
||||
if bb.truncated {
|
||||
// The cap may cut a multi-byte rune: drop at most UTFMax-1 trailing bytes.
|
||||
for i := 0; i < utf8.UTFMax-1 && !utf8.Valid(raw) && len(raw) > 0; i++ {
|
||||
raw = raw[:len(raw)-1]
|
||||
|
||||
@@ -0,0 +1,285 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"compress/flate"
|
||||
"compress/gzip"
|
||||
"compress/zlib"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Autumn-27/artex/db"
|
||||
"github.com/Autumn-27/artex/evidence"
|
||||
)
|
||||
|
||||
const (
|
||||
decodeGzipHead = "HTTP 200\nContent-Type: text/html\nContent-Encoding: gzip\n"
|
||||
decodeHTML = "<html>\n <body>user admin:\tx7\n end of dump</body>\n</html>"
|
||||
decodeQuote = "user admin: x7 end of dump"
|
||||
)
|
||||
|
||||
// compressBody runs data through the compressor built by newWriter.
|
||||
func compressBody(t *testing.T, newWriter func(io.Writer) io.WriteCloser, data []byte) []byte {
|
||||
t.Helper()
|
||||
var buf bytes.Buffer
|
||||
w := newWriter(&buf)
|
||||
if _, err := w.Write(data); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := w.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return buf.Bytes()
|
||||
}
|
||||
|
||||
func gzipBody(t *testing.T, data []byte) []byte {
|
||||
return compressBody(t, func(w io.Writer) io.WriteCloser { return gzip.NewWriter(w) }, data)
|
||||
}
|
||||
|
||||
// decodeSnapshot stores raw as a response body in a database-less evidence
|
||||
// store (same layout as evidence.hashPath) and returns the snapshot bound to it.
|
||||
func decodeSnapshot(t *testing.T, respHead string, raw []byte) (*evidence.Store, db.TrafficEvidenceSnapshot) {
|
||||
t.Helper()
|
||||
sum := sha256.Sum256(raw)
|
||||
hash := hex.EncodeToString(sum[:])
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "blobs", hash[:2], hash+".bin")
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(path, raw, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return &evidence.Store{Dir: dir}, db.TrafficEvidenceSnapshot{RespHead: respHead, RespHash: hash, RespLen: int64(len(raw))}
|
||||
}
|
||||
|
||||
func TestDecodeEvidenceBodyGzipQuoteFound(t *testing.T) {
|
||||
raw := gzipBody(t, []byte(decodeHTML))
|
||||
if strings.Contains(string(raw), "admin") {
|
||||
t.Fatal("fixture is not compressed")
|
||||
}
|
||||
out, truncated, err := decodeEvidenceBody(decodeGzipHead, raw, verificationReadCap)
|
||||
if err != nil || truncated {
|
||||
t.Fatalf("decode: truncated=%v err=%v", truncated, err)
|
||||
}
|
||||
if string(out) != decodeHTML {
|
||||
t.Fatalf("decoded %q, want %q", out, decodeHTML)
|
||||
}
|
||||
if !strings.Contains(normalizeEvidenceText(string(out)), normalizeEvidenceText(decodeQuote)) {
|
||||
t.Fatalf("quote %q not found in decoded body", decodeQuote)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodeEvidenceBodyTruncatedGzipFails(t *testing.T) {
|
||||
raw := gzipBody(t, []byte(strings.Repeat(decodeHTML, 50)))
|
||||
for _, cut := range []int{5, len(raw) / 2, len(raw) - 1} {
|
||||
out, truncated, err := decodeEvidenceBody(decodeGzipHead, raw[:cut], verificationReadCap)
|
||||
if err == nil || out != nil || truncated {
|
||||
t.Fatalf("cut at %d: out=%d bytes truncated=%v err=%v, want an error and no bytes", cut, len(out), truncated, err)
|
||||
}
|
||||
}
|
||||
// Not gzip at all: the raw bytes must not come back either.
|
||||
out, _, err := decodeEvidenceBody(decodeGzipHead, []byte(decodeHTML), verificationReadCap)
|
||||
if err == nil || out != nil {
|
||||
t.Fatalf("plain text declared as gzip: out=%q err=%v, want an error and no bytes", out, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodeEvidenceBodyIdentityAndNoHeaderUnchanged(t *testing.T) {
|
||||
raw := []byte(decodeHTML)
|
||||
for _, head := range []string{
|
||||
"",
|
||||
"HTTP 200\nContent-Type: text/html\n",
|
||||
"HTTP 200\nContent-Encoding: identity\n",
|
||||
"HTTP 200\r\ncontent-encoding: Identity \r\n",
|
||||
} {
|
||||
out, truncated, err := decodeEvidenceBody(head, raw, verificationReadCap)
|
||||
if err != nil || truncated || !bytes.Equal(out, raw) {
|
||||
t.Fatalf("head %q: out=%q truncated=%v err=%v, want the raw bytes", head, out, truncated, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodeEvidenceBodyUnknownEncodingFails(t *testing.T) {
|
||||
raw := gzipBody(t, []byte(decodeHTML))
|
||||
for _, head := range []string{
|
||||
"HTTP 200\nContent-Encoding: br\n",
|
||||
"HTTP 200\nContent-Encoding: gzip, br\n",
|
||||
"HTTP 200\nContent-Encoding: gzip\nContent-Encoding: zstd\n",
|
||||
} {
|
||||
out, _, err := decodeEvidenceBody(head, raw, verificationReadCap)
|
||||
if err == nil || !strings.Contains(err.Error(), "unsupported content-encoding") || out != nil {
|
||||
t.Fatalf("head %q: out=%d bytes err=%v, want unsupported content-encoding", head, len(out), err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodeEvidenceBodyTwoLayersFails(t *testing.T) {
|
||||
raw := gzipBody(t, gzipBody(t, []byte(decodeHTML)))
|
||||
for _, head := range []string{
|
||||
"HTTP 200\nContent-Encoding: gzip, gzip\n",
|
||||
"HTTP 200\nContent-Encoding: gzip\nContent-Encoding: deflate\n",
|
||||
} {
|
||||
out, _, err := decodeEvidenceBody(head, raw, verificationReadCap)
|
||||
if err == nil || !strings.Contains(err.Error(), "unsupported content-encoding") || out != nil {
|
||||
t.Fatalf("head %q: out=%d bytes err=%v, want unsupported content-encoding", head, len(out), err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodeEvidenceBodyOverLimitTruncated(t *testing.T) {
|
||||
const limit = 1000
|
||||
plain := []byte(strings.Repeat("0123456789", 500))
|
||||
out, truncated, err := decodeEvidenceBody(decodeGzipHead, gzipBody(t, plain), limit)
|
||||
if err != nil || !truncated || len(out) != limit || !bytes.Equal(out, plain[:limit]) {
|
||||
t.Fatalf("over limit: len=%d truncated=%v err=%v, want %d bytes truncated", len(out), truncated, err, limit)
|
||||
}
|
||||
// Exactly at the limit is complete, not truncated.
|
||||
out, truncated, err = decodeEvidenceBody(decodeGzipHead, gzipBody(t, plain[:limit]), limit)
|
||||
if err != nil || truncated || len(out) != limit {
|
||||
t.Fatalf("at limit: len=%d truncated=%v err=%v, want %d bytes not truncated", len(out), truncated, err, limit)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodeEvidenceBodyUppercaseHeader(t *testing.T) {
|
||||
raw := gzipBody(t, []byte(decodeHTML))
|
||||
for _, head := range []string{
|
||||
"HTTP 200\nContent-Encoding: GZIP\n",
|
||||
"HTTP 200\r\nCONTENT-ENCODING: GZIP\r\n",
|
||||
} {
|
||||
out, truncated, err := decodeEvidenceBody(head, raw, verificationReadCap)
|
||||
if err != nil || truncated || string(out) != decodeHTML {
|
||||
t.Fatalf("head %q: out=%q truncated=%v err=%v", head, out, truncated, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodeEvidenceBodyDeflate(t *testing.T) {
|
||||
const head = "HTTP 200\nContent-Encoding: deflate\n"
|
||||
bodies := map[string][]byte{
|
||||
"zlib": compressBody(t, func(w io.Writer) io.WriteCloser { return zlib.NewWriter(w) }, []byte(decodeHTML)),
|
||||
"raw": compressBody(t, func(w io.Writer) io.WriteCloser {
|
||||
fw, err := flate.NewWriter(w, flate.DefaultCompression)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return fw
|
||||
}, []byte(decodeHTML)),
|
||||
}
|
||||
for name, raw := range bodies {
|
||||
out, truncated, err := decodeEvidenceBody(head, raw, verificationReadCap)
|
||||
if err != nil || truncated || string(out) != decodeHTML {
|
||||
t.Fatalf("%s deflate: out=%q truncated=%v err=%v", name, out, truncated, err)
|
||||
}
|
||||
if out, _, err := decodeEvidenceBody(head, raw[:len(raw)-1], verificationReadCap); err == nil || out != nil {
|
||||
t.Fatalf("truncated %s deflate: out=%d bytes err=%v, want an error and no bytes", name, len(out), err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodeBoundResponseGzipTextIsSearchable(t *testing.T) {
|
||||
store, snap := decodeSnapshot(t, decodeGzipHead, gzipBody(t, []byte(decodeHTML)))
|
||||
bb, err := readBoundResponse(store, snap)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if bb.nonText || bb.truncated {
|
||||
t.Fatalf("gzip html: nonText=%v truncated=%v", bb.nonText, bb.truncated)
|
||||
}
|
||||
if !quoteInBodies(normalizeEvidenceText(decodeQuote), []boundBody{bb}) {
|
||||
t.Fatalf("quote %q not found in %q", decodeQuote, bb.text)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodeBoundResponseGzipBinaryStaysNonText(t *testing.T) {
|
||||
binary := append([]byte("PNG\x00\x01\x02 user admin: x7 end of dump "), 0xff, 0xfe)
|
||||
store, snap := decodeSnapshot(t, decodeGzipHead, gzipBody(t, binary))
|
||||
bb, err := readBoundResponse(store, snap)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !bb.nonText || bb.text != "" {
|
||||
t.Fatalf("gzip binary: nonText=%v text=%q, want non-text", bb.nonText, bb.text)
|
||||
}
|
||||
if quoteInBodies(normalizeEvidenceText(decodeQuote), []boundBody{bb}) {
|
||||
t.Fatal("quote matched inside a non-text body")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodeBoundResponseBadGzipIsAnError(t *testing.T) {
|
||||
raw := gzipBody(t, []byte(decodeHTML))
|
||||
store, snap := decodeSnapshot(t, decodeGzipHead, raw[:len(raw)-4])
|
||||
if bb, err := readBoundResponse(store, snap); err == nil {
|
||||
t.Fatalf("truncated gzip: got %+v, want an error", bb)
|
||||
}
|
||||
store, snap = decodeSnapshot(t, "HTTP 200\nContent-Encoding: br\n", raw)
|
||||
if bb, err := readBoundResponse(store, snap); err == nil || !strings.Contains(err.Error(), "unsupported content-encoding") {
|
||||
t.Fatalf("br: got %+v err=%v, want unsupported content-encoding", bb, err)
|
||||
}
|
||||
}
|
||||
|
||||
// A response captured with Content-Encoding: gzip and no body at all (HEAD,
|
||||
// 304) is not a decode failure: it stays an empty body, without error.
|
||||
func TestDecodeEvidenceBodyEmptyBodyWithEncodingStaysEmpty(t *testing.T) {
|
||||
for _, head := range []string{
|
||||
decodeGzipHead,
|
||||
"HTTP 304\r\nCONTENT-ENCODING: GZIP\r\n",
|
||||
"HTTP 200\nContent-Encoding: deflate\n",
|
||||
} {
|
||||
for _, raw := range [][]byte{nil, {}} {
|
||||
out, truncated, err := decodeEvidenceBody(head, raw, verificationReadCap)
|
||||
if err != nil || truncated || len(out) != 0 {
|
||||
t.Fatalf("head %q: out=%q truncated=%v err=%v, want an empty body and no error", head, out, truncated, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// An empty bound body, compressed or not, is read without error but can never
|
||||
// satisfy a quote. In assessFindingVerification a quote that matches no body
|
||||
// only leads to unverified or not_assessed, never verified, so this is what
|
||||
// keeps an empty response (HEAD, 304) from verifying a finding.
|
||||
func TestDecodeBoundResponseEmptyBodyNeverMatchesQuote(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
head string
|
||||
raw []byte
|
||||
}{
|
||||
{"gzip header, no bytes", decodeGzipHead, nil},
|
||||
{"deflate header, no bytes", "HTTP 200\nContent-Encoding: deflate\n", nil},
|
||||
{"identity header, no bytes", "HTTP 200\nContent-Encoding: identity\n", nil},
|
||||
{"no encoding header, no bytes", "HTTP 304\nContent-Type: text/html\n", nil},
|
||||
{"gzip stream of an empty payload", decodeGzipHead, gzipBody(t, nil)},
|
||||
}
|
||||
quotes := []string{
|
||||
decodeQuote,
|
||||
"x",
|
||||
// Present in the response head: the head is not searchable text.
|
||||
"Content-Encoding: gzip",
|
||||
"HTTP 200",
|
||||
}
|
||||
for _, c := range cases {
|
||||
store, snap := decodeSnapshot(t, c.head, c.raw)
|
||||
bb, err := readBoundResponse(store, snap)
|
||||
if err != nil {
|
||||
t.Fatalf("%s: %v", c.name, err)
|
||||
}
|
||||
if bb.text != "" || bb.nonText || bb.truncated {
|
||||
t.Fatalf("%s: got %+v, want an empty text body", c.name, bb)
|
||||
}
|
||||
for _, q := range quotes {
|
||||
nq := normalizeEvidenceText(q)
|
||||
if nq == "" {
|
||||
t.Fatalf("quote %q normalizes to nothing", q)
|
||||
}
|
||||
if quoteInBodies(nq, []boundBody{bb}) {
|
||||
t.Fatalf("%s: quote %q matched an empty body", c.name, q)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -536,6 +536,7 @@ func (s *Server) seedOrchestrationTools() {
|
||||
s.seedWorkerReadToolsUnbind() // list_facts/list_companies/list_worker_traces unbound from worker's defaults (one-time)
|
||||
s.seedWorkerReadbackRebind() // repairs an old migration's wrong removal: re-binds search_all_worker_traces/get_worker_trace/node_detail back onto worker (one-time)
|
||||
s.seedTaskCredentialBindings() // save/list_task_credential onto worker + pentest (one-time, #3)
|
||||
s.healListTaskCredentialsSchema()
|
||||
s.seedAutoReportFindingBinding()
|
||||
s.unbindGoalMetDefault()
|
||||
s.reseedGoalsPrompt() // goals prompt gains the "extract operation constraints" step → old DBs append a new default version (one-time)
|
||||
@@ -944,6 +945,19 @@ func (s *Server) seedTaskCredentialBindings() {
|
||||
_ = s.m.pg.SetSetting(flag, "true")
|
||||
}
|
||||
|
||||
// healListTaskCredentialsSchema repairs the list_task_credentials row seeded by
|
||||
// older builds, whose schema carried "required": [""] — strict providers reject
|
||||
// it, which fails every worker run. SeedTool is first-insert-only, so the code fix
|
||||
// alone never reaches an already-seeded row. Only that exact malformed value on
|
||||
// that one key matches, and only the "required" key is dropped: the rest of the
|
||||
// schema, the description, bindings and enabled flag are untouched. A healed row
|
||||
// no longer matches, so this needs no settings flag and is a no-op afterwards.
|
||||
func (s *Server) healListTaskCredentialsSchema() {
|
||||
if _, err := s.m.pg.Exec(`UPDATE tools SET schema = schema - 'required', updated_at=now() WHERE key='list_task_credentials' AND schema->'required' = '[""]'::jsonb`); err != nil {
|
||||
log.Printf("[tools] heal list_task_credentials schema failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) seedWorkerReadbackRebind() {
|
||||
const flag = "worker_readback_rebind_v2" // v2: adds node_detail
|
||||
if v, _, _ := s.m.pg.GetSetting(flag); v == "true" {
|
||||
|
||||
@@ -103,6 +103,96 @@ func TestWireTools(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestHealListTaskCredentialsSchema: a list_task_credentials row seeded by an older
|
||||
// build carries "required": [""]. The startup heal drops that key, leaves the rest of
|
||||
// the row alone, changes nothing on a second run, and touches no other tool.
|
||||
func TestHealListTaskCredentialsSchema(t *testing.T) {
|
||||
dsn, _, err := db.DSN()
|
||||
if err != nil {
|
||||
t.Skipf("no database config (%v) — skipping", err)
|
||||
}
|
||||
pg, err := db.Open(dsn)
|
||||
if err != nil {
|
||||
t.Skipf("postgres unavailable (%v) — skipping", err)
|
||||
}
|
||||
t.Cleanup(func() { pg.Close() })
|
||||
s := &Server{m: &Manager{pg: pg}}
|
||||
|
||||
const key = "list_task_credentials"
|
||||
// Other tools the heal must never touch: a normal required list, and the same
|
||||
// malformed value under a different key.
|
||||
const probeRequired, probeBlank = "heal_probe_required", "heal_probe_blank"
|
||||
const broken = `{"type":"object","properties":{},"required":[""]}`
|
||||
|
||||
// The package test DB is shared: put back whatever it held for the real key.
|
||||
old, err := pg.GetTool(key)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
if old == nil {
|
||||
pg.Exec(`DELETE FROM tools WHERE key=$1`, key)
|
||||
} else {
|
||||
pg.Exec(`UPDATE tools SET schema=$2::jsonb WHERE key=$1`, key, string(old.Schema))
|
||||
}
|
||||
pg.Exec(`DELETE FROM tools WHERE key IN ($1,$2)`, probeRequired, probeBlank)
|
||||
})
|
||||
for k, schema := range map[string]string{
|
||||
key: broken,
|
||||
probeRequired: `{"type":"object","properties":{"x":{"type":"string"}},"required":["x"]}`,
|
||||
probeBlank: broken,
|
||||
} {
|
||||
if _, err := pg.Exec(`INSERT INTO tools(key, description, schema, agents) VALUES ($1, 'legacy row', $2::jsonb, '["worker"]')
|
||||
ON CONFLICT (key) DO UPDATE SET schema=EXCLUDED.schema`, k, schema); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
type row struct {
|
||||
schema, description, agents, updatedAt string
|
||||
enabled bool
|
||||
}
|
||||
read := func(k string) row {
|
||||
t.Helper()
|
||||
var r row
|
||||
if err := pg.QueryRow(`SELECT schema::text, description, agents::text, updated_at::text, enabled FROM tools WHERE key=$1`, k).
|
||||
Scan(&r.schema, &r.description, &r.agents, &r.updatedAt, &r.enabled); err != nil {
|
||||
t.Fatalf("read %s: %v", k, err)
|
||||
}
|
||||
return r
|
||||
}
|
||||
before, beforeRequired, beforeBlank := read(key), read(probeRequired), read(probeBlank)
|
||||
|
||||
s.healListTaskCredentialsSchema()
|
||||
|
||||
healed := read(key)
|
||||
var schema map[string]any
|
||||
if err := json.Unmarshal([]byte(healed.schema), &schema); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, ok := schema["required"]; ok {
|
||||
t.Fatalf("required survived the heal: %s", healed.schema)
|
||||
}
|
||||
if schema["type"] != "object" || schema["properties"] == nil {
|
||||
t.Errorf("heal dropped more than required: %s", healed.schema)
|
||||
}
|
||||
if healed.description != before.description || healed.agents != before.agents || healed.enabled != before.enabled {
|
||||
t.Errorf("heal changed description/bindings/enabled: %+v -> %+v", before, healed)
|
||||
}
|
||||
|
||||
s.healListTaskCredentialsSchema()
|
||||
|
||||
if again := read(key); again != healed {
|
||||
t.Errorf("second heal changed the row: %+v -> %+v", healed, again)
|
||||
}
|
||||
if got := read(probeRequired); got != beforeRequired {
|
||||
t.Errorf("heal touched a tool with a non-empty required: %+v -> %+v", beforeRequired, got)
|
||||
}
|
||||
if got := read(probeBlank); got != beforeBlank {
|
||||
t.Errorf("heal touched another tool key: %+v -> %+v", beforeBlank, got)
|
||||
}
|
||||
}
|
||||
|
||||
func mustJSON(v any) json.RawMessage {
|
||||
b, _ := json.Marshal(v)
|
||||
return b
|
||||
|
||||
Reference in New Issue
Block a user