mirror of
https://github.com/BaaRai/ARTEX.git
synced 2026-11-14 04:00:54 +08:00
Compare commits
23
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a422a071f6 | ||
|
|
3b6eb2fd2c | ||
|
|
2ab1046746 | ||
|
|
809436e111 | ||
|
|
c84f124cb4 | ||
|
|
cf0b0c1002 | ||
|
|
b17a0ea796 | ||
|
|
59c3702a5e | ||
|
|
05df40eecb | ||
|
|
f4f0df128a | ||
|
|
00f5d8c103 | ||
|
|
a44af3ca2b | ||
|
|
81eab87d16 | ||
|
|
06453409ea | ||
|
|
c73e2872a3 | ||
|
|
a83f494d03 | ||
|
|
52ea3f65c6 | ||
|
|
2218480eba | ||
|
|
8f50b81792 | ||
|
|
3e75f1f954 | ||
|
|
8634ddbf23 | ||
|
|
ec89070b8e | ||
|
|
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
|
||||
|
||||
+8
-2
@@ -633,14 +633,20 @@ LIMIT $1`, malformedIPAssetsSampled)
|
||||
if total == 0 {
|
||||
return "", nil
|
||||
}
|
||||
return malformedIPAssetWarningText(total, samples), nil
|
||||
}
|
||||
|
||||
// malformedIPAssetWarningText words the warning for total offending assets, of
|
||||
// which only samples are named; the ones left out are counted in the suffix.
|
||||
func malformedIPAssetWarningText(total int, samples []string) string {
|
||||
warning := fmt.Sprintf(
|
||||
"%d assets have an ip field that is not a valid IP; skipped for IP/CIDR scope matching (these assets will not be attributed to a company by subnet rules): %s",
|
||||
total, strings.Join(samples, ", "),
|
||||
)
|
||||
if total > len(samples) {
|
||||
warning += fmt.Sprintf(" and %d more", total)
|
||||
warning += fmt.Sprintf(" and %d more", total-len(samples))
|
||||
}
|
||||
return warning, nil
|
||||
return warning
|
||||
}
|
||||
|
||||
// UpdateScope replaces all scope rules for a company and reattributes.
|
||||
|
||||
@@ -458,3 +458,21 @@ func TestListCompanies(t *testing.T) {
|
||||
t.Error("ListCompanies: created company not found")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMalformedIPAssetWarningTextCountsOmitted(t *testing.T) {
|
||||
samples := []string{"#1 a", "#2 b", "#3 c", "#4 d", "#5 e"}
|
||||
// The suffix must count the assets left out, not repeat the total, otherwise
|
||||
// the operator is told about more bad rows than exist.
|
||||
got := malformedIPAssetWarningText(8, samples)
|
||||
if !strings.HasPrefix(got, "8 assets have an ip field") {
|
||||
t.Fatalf("the warning should lead with the total of 8, got %q", got)
|
||||
}
|
||||
if !strings.HasSuffix(got, ": #1 a, #2 b, #3 c, #4 d, #5 e and 3 more") {
|
||||
t.Fatalf("8 assets with 5 named should note the 3 omitted, got %q", got)
|
||||
}
|
||||
// When every offending asset is named there is nothing to add.
|
||||
got = malformedIPAssetWarningText(5, samples)
|
||||
if !strings.HasSuffix(got, ": #1 a, #2 b, #3 c, #4 d, #5 e") {
|
||||
t.Fatalf("5 assets with 5 named should have no suffix, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
+27
-2
@@ -12,6 +12,19 @@ import (
|
||||
|
||||
const FindingRetestAgentKey = "retester"
|
||||
|
||||
// retestNoteSummaryRunes caps the retest summary copied into verification_note.
|
||||
const retestNoteSummaryRunes = 200
|
||||
|
||||
// truncateRunes returns s trimmed to at most n runes, with an ellipsis when cut.
|
||||
func truncateRunes(s string, n int) string {
|
||||
s = strings.TrimSpace(s)
|
||||
r := []rune(s)
|
||||
if len(r) <= n {
|
||||
return s
|
||||
}
|
||||
return string(r[:n]) + "…"
|
||||
}
|
||||
|
||||
var ErrRetestNotRunning = errors.New("this retest has ended or has not started; please start a new retest from the finding details")
|
||||
|
||||
// FindingRetest is an immutable historical test once its conversation turn ends.
|
||||
@@ -225,17 +238,29 @@ func (d *DB) FinishFindingRetest(id int64, status, reason string) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var finalStatus, verdict string
|
||||
var finalStatus, verdict, summary string
|
||||
err = tx.QueryRow(`UPDATE finding_retests SET
|
||||
status=CASE WHEN $2='completed' AND verdict='' THEN 'failed' ELSE $2 END,
|
||||
error=CASE WHEN $2='completed' AND verdict='' THEN 'Agent did not save the retest conclusion; please review the conversation and retest again' ELSE $3 END,
|
||||
finished_at=now() WHERE id=$1 AND status IN ('pending','running') RETURNING status,verdict`, id, status, reason).Scan(&finalStatus, &verdict)
|
||||
finished_at=now() WHERE id=$1 AND status IN ('pending','running') RETURNING status,verdict,summary`, id, status, reason).Scan(&finalStatus, &verdict, &summary)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil // A replay must not overwrite a later manual triage decision.
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// A completed reproduced/fixed retest is an independent proof: it upgrades the
|
||||
// machine verification state, but only from unverified / not_assessed / blocked.
|
||||
// A verified finding keeps its POE level and note. An inconclusive, failed or
|
||||
// stopped retest never downgrades anything; its verdict stays in finding_retests.
|
||||
// The human disposition (status) is not touched here.
|
||||
if finalStatus == "completed" && (verdict == "reproduced" || verdict == "fixed") {
|
||||
note := fmt.Sprintf("retest #%d: %s — %s", id, verdict, truncateRunes(summary, retestNoteSummaryRunes))
|
||||
if _, err := tx.Exec(`UPDATE findings SET verification_status='verified', verification_note=$2
|
||||
WHERE id=$1 AND verification_status IN ('unverified','not_assessed','blocked')`, findingID, note); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if finalStatus == "completed" && verdict == "fixed" {
|
||||
// Go through the notifying version, sharing the same semantics as a
|
||||
// manual status change on the details page.
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// runRetest drives one retest session to its terminal state with the given
|
||||
// staged verdict, using the same calls the server makes, and returns its ID.
|
||||
func runRetest(t *testing.T, d *DB, fid int64, verdict, summary, finish string) int64 {
|
||||
t.Helper()
|
||||
ctx := t.Context()
|
||||
r, c, _, err := d.CreateFindingRetest(ctx, fid, "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if ok, err := d.StartFindingRetest(ctx, r.ID); err != nil || !ok {
|
||||
t.Fatalf("start retest: ok=%v err=%v", ok, err)
|
||||
}
|
||||
if verdict != "" {
|
||||
if err := d.RecordFindingRetestResult(ctx, c.ID, verdict, summary, "retest evidence"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := d.FinishFindingRetest(r.ID, finish, ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return r.ID
|
||||
}
|
||||
|
||||
func verificationOf(t *testing.T, d *DB, fid int64) (status string, poe int, note string) {
|
||||
t.Helper()
|
||||
if err := d.QueryRow(`SELECT verification_status, poe_level, verification_note FROM findings WHERE id=$1`, fid).
|
||||
Scan(&status, &poe, ¬e); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func TestRetestReproducedVerifiesUnverifiedFinding(t *testing.T) {
|
||||
d, fid := retestDB(t)
|
||||
runRetest(t, d, fid, "reproduced", "still exploitable with the original PoC", "completed")
|
||||
status, _, note := verificationOf(t, d, fid)
|
||||
if status != VerificationVerified || !strings.Contains(note, "retest") || !strings.Contains(note, "reproduced") {
|
||||
t.Fatalf("verification after reproduced: status=%s note=%q", status, note)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRetestFixedVerifiesAndKeepsFixedStatus(t *testing.T) {
|
||||
d, fid := retestDB(t)
|
||||
runRetest(t, d, fid, "fixed", "payload now rejected, control still works", "completed")
|
||||
status, _, note := verificationOf(t, d, fid)
|
||||
if status != VerificationVerified || !strings.Contains(note, "retest") || !strings.Contains(note, "fixed") {
|
||||
t.Fatalf("verification after fixed: status=%s note=%q", status, note)
|
||||
}
|
||||
f, err := d.GetFinding(fid)
|
||||
if err != nil || f == nil {
|
||||
t.Fatalf("get finding: %v", err)
|
||||
}
|
||||
if f.Status != FindingFixed {
|
||||
t.Fatalf("human status after fixed retest = %q, want %q", f.Status, FindingFixed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRetestInconclusiveDoesNotDowngradeVerified(t *testing.T) {
|
||||
d, fid := retestDB(t)
|
||||
if err := d.SetFindingVerification(fid, VerificationVerified, 3, ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
runRetest(t, d, fid, "inconclusive", "target unreachable from the retest session", "completed")
|
||||
status, poe, note := verificationOf(t, d, fid)
|
||||
if status != VerificationVerified || poe != 3 || note != "" {
|
||||
t.Fatalf("verified finding after inconclusive: status=%s poe=%d note=%q", status, poe, note)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRetestFailedOrStoppedLeavesVerificationUnchanged(t *testing.T) {
|
||||
for _, finish := range []string{"failed", "stopped"} {
|
||||
t.Run(finish, func(t *testing.T) {
|
||||
d, fid := retestDB(t)
|
||||
if err := d.SetFindingVerification(fid, VerificationNotAssessed, 3, "earlier note"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// A verdict staged before the session was cut off must not count.
|
||||
runRetest(t, d, fid, "reproduced", "staged before interruption", finish)
|
||||
status, poe, note := verificationOf(t, d, fid)
|
||||
if status != VerificationNotAssessed || poe != 3 || note != "earlier note" {
|
||||
t.Fatalf("after %s: status=%s poe=%d note=%q", finish, status, poe, note)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRetestUpgradesFromNotAssessedAndBlocked(t *testing.T) {
|
||||
for _, from := range []string{VerificationNotAssessed, VerificationBlocked} {
|
||||
t.Run(from, func(t *testing.T) {
|
||||
d, fid := retestDB(t)
|
||||
if err := d.SetFindingVerification(fid, from, 0, ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
runRetest(t, d, fid, "reproduced", "reproduced on retest", "completed")
|
||||
if status, _, _ := verificationOf(t, d, fid); status != VerificationVerified {
|
||||
t.Fatalf("from %s after reproduced: status=%s", from, status)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
+26
-8
@@ -27,6 +27,8 @@ type InterceptRule struct {
|
||||
}
|
||||
|
||||
// InterceptPending is one row of intercept_pending.
|
||||
// DecidedBy is the actor that settled the request: "" while pending, otherwise
|
||||
// human | rule | model | system (timeouts and cancellations).
|
||||
type InterceptPending struct {
|
||||
ID int64 `json:"id"`
|
||||
RuleID *int64 `json:"rule_id"`
|
||||
@@ -37,6 +39,7 @@ type InterceptPending struct {
|
||||
ToolInput json.RawMessage `json:"tool_input"`
|
||||
Status string `json:"status"`
|
||||
DecisionSource string `json:"decision_source"`
|
||||
DecidedBy string `json:"decided_by"`
|
||||
Reason string `json:"reason"` // rule message or model decision reason (prefixed with ModelReasonPrefix)
|
||||
DecidedAt *time.Time `json:"decided_at"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
@@ -163,19 +166,25 @@ func (d *DB) CreateDecidedIntercept(ruleID, convID int64, taskID, agentName, too
|
||||
if ruleID != 0 {
|
||||
ruleIDPtr = &ruleID
|
||||
}
|
||||
// Rows logged here were settled without a human: by the matching rule, or by
|
||||
// the model judge when no rule owns the decision.
|
||||
decidedBy := "model"
|
||||
if ruleID != 0 {
|
||||
decidedBy = "rule"
|
||||
}
|
||||
var id int64
|
||||
err := d.QueryRow(`
|
||||
INSERT INTO intercept_pending(rule_id, conversation_id, task_id, agent_name, tool_name, tool_input, status, reason, decided_at, decision_source, audit)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, NOW(), $9, $10) RETURNING id`,
|
||||
ruleIDPtr, convIDPtr, taskIDPtr, agentName, toolName, raw, status, reason, interceptSource(ruleID, reason), firstAudit(audits)).Scan(&id)
|
||||
INSERT INTO intercept_pending(rule_id, conversation_id, task_id, agent_name, tool_name, tool_input, status, reason, decided_at, decision_source, audit, decided_by)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, NOW(), $9, $10, $11) RETURNING id`,
|
||||
ruleIDPtr, convIDPtr, taskIDPtr, agentName, toolName, raw, status, reason, interceptSource(ruleID, reason), firstAudit(audits), decidedBy).Scan(&id)
|
||||
return id, err
|
||||
}
|
||||
|
||||
const interceptPendingCols = `id, rule_id, conversation_id, task_id, agent_name, tool_name, tool_input, status, reason, decided_at, created_at, decision_source`
|
||||
const interceptPendingCols = `id, rule_id, conversation_id, task_id, agent_name, tool_name, tool_input, status, reason, decided_at, created_at, decision_source, decided_by`
|
||||
|
||||
func scanInterceptPending(s interface{ Scan(...any) error }, p *InterceptPending) error {
|
||||
return s.Scan(&p.ID, &p.RuleID, &p.ConversationID, &p.TaskID, &p.AgentName,
|
||||
&p.ToolName, &p.ToolInput, &p.Status, &p.Reason, &p.DecidedAt, &p.CreatedAt, &p.DecisionSource)
|
||||
&p.ToolName, &p.ToolInput, &p.Status, &p.Reason, &p.DecidedAt, &p.CreatedAt, &p.DecisionSource, &p.DecidedBy)
|
||||
}
|
||||
|
||||
// ListPendingIntercepts returns all unresolved approval requests, newest first.
|
||||
@@ -221,7 +230,7 @@ func scanInterceptApprovalRow(rows interface{ Scan(...any) error }, r *Intercept
|
||||
return rows.Scan(
|
||||
&r.ID, &r.RuleID, &r.ConversationID, &r.TaskID, &r.AgentName,
|
||||
&r.ToolName, &r.ToolInput, &r.Status, &r.Reason, &r.DecidedAt, &r.CreatedAt,
|
||||
&r.DecisionSource, &r.ConvTitle, &r.ConvAgentKey, &r.RuleName,
|
||||
&r.DecisionSource, &r.DecidedBy, &r.ConvTitle, &r.ConvAgentKey, &r.RuleName,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -246,8 +255,12 @@ const approvalDecisionSource = `COALESCE(NULLIF(ip.decision_source,''), CASE
|
||||
WHEN ip.rule_id IS NOT NULL THEN 'rule'
|
||||
WHEN lower(ip.reason) LIKE '` + ModelReasonPrefix + `%' OR ip.reason LIKE '` + legacyModelReasonPrefix + `%' THEN 'model' ELSE 'unknown' END)`
|
||||
|
||||
// decided_by is returned exactly as stored, never merged with decision_source:
|
||||
// rows decided before the column existed keep it empty, so the displayed value
|
||||
// is always the one the decided_by filter matches.
|
||||
const approvalRowColumns = `ip.id, ip.rule_id, ip.conversation_id, ip.task_id, ip.agent_name,
|
||||
ip.tool_name, ip.tool_input, ip.status, ip.reason, ip.decided_at, ip.created_at, ` + approvalDecisionSource + `,
|
||||
ip.decided_by,
|
||||
COALESCE(c.title,'') AS conv_title,
|
||||
COALESCE(c.agent_key,'') AS conv_agent_key,
|
||||
COALESCE(ir.name,'') AS rule_name`
|
||||
@@ -300,11 +313,12 @@ func (d *DB) ListAllIntercepts(limit int) ([]InterceptApprovalRow, error) {
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// InterceptApprovalFilter combines exact status and decision-source filters.
|
||||
// Empty fields include all values.
|
||||
// InterceptApprovalFilter combines exact status, decision-source and
|
||||
// deciding-actor filters. Empty fields include all values.
|
||||
type InterceptApprovalFilter struct {
|
||||
Status string
|
||||
DecisionSource string
|
||||
DecidedBy string
|
||||
}
|
||||
|
||||
// ListAllInterceptsPage returns one 1-based page and the total matching count.
|
||||
@@ -358,6 +372,10 @@ func (d *DB) listInterceptsPage(taskID string, page, size int, filter InterceptA
|
||||
add("ip.task_id", taskID)
|
||||
add("ip.status", filter.Status)
|
||||
add(approvalDecisionSource, filter.DecisionSource)
|
||||
// Stored actor only, the same value the rows display: deriving it from
|
||||
// decision_source would let decided_by=model match human decisions and
|
||||
// timeouts recorded before the column existed.
|
||||
add("ip.decided_by", filter.DecidedBy)
|
||||
where := ""
|
||||
if len(conditions) > 0 {
|
||||
where = " WHERE " + strings.Join(conditions, " AND ")
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// The actor whitelist is enforced in Go (there is no SQL CHECK). It must reject
|
||||
// before touching the database, so this runs without Postgres.
|
||||
func TestResolveInterceptRejectsUnknownActor(t *testing.T) {
|
||||
d := &DB{}
|
||||
for _, actor := range []string{"", "unknown", "Human", "admin"} {
|
||||
if ok, err := d.ResolveIntercept(1, "allowed", "allow", "fixture", actor); err == nil || ok {
|
||||
t.Fatalf("decided_by %q accepted: ok=%t err=%v", actor, ok, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestInterceptDecidedBy(t *testing.T) {
|
||||
d, err := Open(testDSN(t))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = d.Close() })
|
||||
scope := "approval-decided-by"
|
||||
t.Cleanup(func() { _, _ = d.Exec(`DELETE FROM intercept_pending WHERE task_id=$1`, scope) })
|
||||
rule, err := d.CreateInterceptRule("decided-by fixture", "tool_name", "string", "Bash", "deny", "fixture", 1, false, false, 0, "deny")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _, _ = d.Exec(`DELETE FROM intercept_rules WHERE id=$1`, rule.ID) })
|
||||
|
||||
input := []byte(`{"command":"fixture"}`)
|
||||
decided := func(ruleID int64, status, reason string) int64 {
|
||||
t.Helper()
|
||||
id, err := d.CreateDecidedIntercept(ruleID, 0, scope, "test", "Bash", input, status, reason)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return id
|
||||
}
|
||||
asked := func() int64 {
|
||||
t.Helper()
|
||||
id, err := d.CreateInterceptPending(0, 0, scope, "test", "Bash", input, ModelReasonPrefix+" confirm")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return id
|
||||
}
|
||||
resolved := func(status, action, reason, actor string) int64 {
|
||||
t.Helper()
|
||||
id := asked()
|
||||
if ok, err := d.ResolveIntercept(id, status, action, reason, actor); err != nil || !ok {
|
||||
t.Fatalf("resolve %s/%s: ok=%t err=%v", status, actor, ok, err)
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
ruleID := decided(rule.ID, "denied", "fixture")
|
||||
modelID := decided(0, "allowed", ModelReasonPrefix+" fixture")
|
||||
pendingID := asked()
|
||||
humanAllowID := resolved("allowed", "allow", "user approved execution", "human")
|
||||
humanDenyID := resolved("denied", "deny", "user rejected execution", "human")
|
||||
timeoutID := resolved("timeout", "deny", "approval timed out, applying timeout policy", "system")
|
||||
// A cancellation is a system refusal even though its status is 'denied'.
|
||||
cancelledID := resolved("denied", "deny", "work cancelled", "system")
|
||||
|
||||
want := map[int64]string{
|
||||
ruleID: "rule", modelID: "model", pendingID: "",
|
||||
humanAllowID: "human", humanDenyID: "human",
|
||||
timeoutID: "system", cancelledID: "system",
|
||||
}
|
||||
for id, actor := range want {
|
||||
stored, err := d.GetInterceptPending(id)
|
||||
if err != nil || stored == nil || stored.DecidedBy != actor {
|
||||
t.Fatalf("stored decided_by for %d: %+v err=%v, want %q", id, stored, err, actor)
|
||||
}
|
||||
detail, err := d.GetInterceptDetail(id)
|
||||
if err != nil || detail == nil || detail.DecidedBy != actor {
|
||||
t.Fatalf("detail decided_by for %d: %+v err=%v, want %q", id, detail, err, actor)
|
||||
}
|
||||
}
|
||||
rows, err := d.ListTaskIntercepts(scope)
|
||||
if err != nil || len(rows) != len(want) {
|
||||
t.Fatalf("list: %d rows err=%v", len(rows), err)
|
||||
}
|
||||
for _, r := range rows {
|
||||
if r.DecidedBy != want[r.ID] {
|
||||
t.Fatalf("listed decided_by for %d = %q, want %q", r.ID, r.DecidedBy, want[r.ID])
|
||||
}
|
||||
}
|
||||
|
||||
// A rejected actor must leave the request pending and unattributed.
|
||||
if ok, err := d.ResolveIntercept(pendingID, "allowed", "allow", "fixture", "admin"); err == nil || ok {
|
||||
t.Fatalf("invalid actor resolved the request: ok=%t err=%v", ok, err)
|
||||
}
|
||||
if p, err := d.GetInterceptPending(pendingID); err != nil || p.Status != "pending" || p.DecidedBy != "" {
|
||||
t.Fatalf("pending row changed: %+v err=%v", p, err)
|
||||
}
|
||||
|
||||
filtered := func(filter InterceptApprovalFilter) []int64 {
|
||||
t.Helper()
|
||||
rows, total, err := d.ListTaskInterceptsPage(scope, 1, 100, filter)
|
||||
if err != nil || total != len(rows) {
|
||||
t.Fatalf("filter %+v: total=%d rows=%d err=%v", filter, total, len(rows), err)
|
||||
}
|
||||
ids := []int64{}
|
||||
for _, r := range rows {
|
||||
ids = append(ids, r.ID)
|
||||
}
|
||||
slices.Sort(ids)
|
||||
return ids
|
||||
}
|
||||
for _, tc := range []struct {
|
||||
filter InterceptApprovalFilter
|
||||
want []int64
|
||||
}{
|
||||
// Neither the human decisions nor the timeout of a model-raised ask.
|
||||
{InterceptApprovalFilter{DecidedBy: "model"}, []int64{modelID}},
|
||||
{InterceptApprovalFilter{DecidedBy: "rule"}, []int64{ruleID}},
|
||||
{InterceptApprovalFilter{DecidedBy: "human"}, []int64{humanAllowID, humanDenyID}},
|
||||
{InterceptApprovalFilter{DecidedBy: "system"}, []int64{timeoutID, cancelledID}},
|
||||
{InterceptApprovalFilter{Status: "denied", DecidedBy: "human"}, []int64{humanDenyID}},
|
||||
{InterceptApprovalFilter{Status: "denied", DecidedBy: "system"}, []int64{cancelledID}},
|
||||
{InterceptApprovalFilter{DecidedBy: "human' OR 1=1 --"}, []int64{}},
|
||||
} {
|
||||
if got := filtered(tc.filter); !slices.Equal(got, tc.want) {
|
||||
t.Fatalf("filter %+v: got %v want %v", tc.filter, got, tc.want)
|
||||
}
|
||||
}
|
||||
|
||||
// Rows decided before the column existed: decided_by stays empty everywhere it
|
||||
// is read, decision_source keeps its own column, nothing is rewritten, and no
|
||||
// decided_by filter matches them.
|
||||
if _, err := d.Exec(`UPDATE intercept_pending SET decided_by='' WHERE id IN ($1,$2,$3)`, ruleID, modelID, timeoutID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
legacy := map[int64]string{ruleID: "rule", modelID: "model", timeoutID: "model"}
|
||||
for id, source := range legacy {
|
||||
detail, err := d.GetInterceptDetail(id)
|
||||
if err != nil || detail == nil || detail.DecidedBy != "" || detail.DecisionSource != source {
|
||||
t.Fatalf("legacy detail for %d: %+v err=%v, want decided_by \"\" and decision_source %q", id, detail, err, source)
|
||||
}
|
||||
stored, err := d.GetInterceptPending(id)
|
||||
if err != nil || stored == nil || stored.DecidedBy != "" {
|
||||
t.Fatalf("legacy row %d rewritten: %+v err=%v", id, stored, err)
|
||||
}
|
||||
}
|
||||
rows, err = d.ListTaskIntercepts(scope)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, r := range rows {
|
||||
if source, ok := legacy[r.ID]; ok && (r.DecidedBy != "" || r.DecisionSource != source) {
|
||||
t.Fatalf("legacy list row %d: decided_by=%q decision_source=%q, want \"\" and %q", r.ID, r.DecidedBy, r.DecisionSource, source)
|
||||
}
|
||||
}
|
||||
for _, actor := range []string{"human", "rule", "model", "system"} {
|
||||
for _, id := range filtered(InterceptApprovalFilter{DecidedBy: actor}) {
|
||||
if _, ok := legacy[id]; ok {
|
||||
t.Fatalf("legacy row %d matched decided_by=%s", id, actor)
|
||||
}
|
||||
}
|
||||
}
|
||||
// decision_source still finds them through its own filter.
|
||||
if got := filtered(InterceptApprovalFilter{DecisionSource: "rule"}); !slices.Contains(got, ruleID) {
|
||||
t.Fatalf("legacy rule row missing from decision_source=rule: %v", got)
|
||||
}
|
||||
}
|
||||
+12
-4
@@ -3,6 +3,7 @@ package db
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
@@ -55,7 +56,7 @@ func (d *DB) GetInterceptDetail(id int64) (*InterceptDetail, error) {
|
||||
err := d.QueryRow(approvalRowSelectWithAudit+` WHERE ip.id=$1`, id).Scan(
|
||||
&out.ID, &out.RuleID, &out.ConversationID, &out.TaskID, &out.AgentName,
|
||||
&out.ToolName, &out.ToolInput, &out.Status, &out.Reason, &out.DecidedAt, &out.CreatedAt,
|
||||
&out.DecisionSource, &out.ConvTitle, &out.ConvAgentKey, &out.RuleName, &raw,
|
||||
&out.DecisionSource, &out.DecidedBy, &out.ConvTitle, &out.ConvAgentKey, &out.RuleName, &raw,
|
||||
)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
@@ -73,7 +74,14 @@ func (d *DB) GetInterceptDetail(id int64) (*InterceptDetail, error) {
|
||||
|
||||
// ResolveIntercept atomically settles a pending request. A timeout cannot
|
||||
// overwrite a human decision and repeat decisions cannot rewrite history.
|
||||
func (d *DB) ResolveIntercept(id int64, status, action, reason string) (bool, error) {
|
||||
// decidedBy records the settling actor (human | rule | model | system); it is
|
||||
// supplied by the caller because status alone cannot tell them apart.
|
||||
func (d *DB) ResolveIntercept(id int64, status, action, reason, decidedBy string) (bool, error) {
|
||||
switch decidedBy {
|
||||
case "human", "rule", "model", "system":
|
||||
default:
|
||||
return false, fmt.Errorf("decided_by must be human, rule, model or system, got %q", decidedBy)
|
||||
}
|
||||
execution := "not_executed"
|
||||
if action == "allow" {
|
||||
execution = "awaiting_result"
|
||||
@@ -84,11 +92,11 @@ func (d *DB) ResolveIntercept(id int64, status, action, reason string) (bool, er
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
r, err := d.Exec(`UPDATE intercept_pending SET status=$2, decided_at=NOW(),
|
||||
r, err := d.Exec(`UPDATE intercept_pending SET status=$2, decided_at=NOW(), decided_by=$5,
|
||||
audit=CASE WHEN audit IS NULL THEN NULL ELSE audit || $3::jsonb ||
|
||||
CASE WHEN $4='allow' AND audit->>'correlation' IS DISTINCT FROM 'exact'
|
||||
THEN '{"execution_status":"unknown"}'::jsonb ELSE '{}'::jsonb END END
|
||||
WHERE id=$1 AND status='pending'`, id, status, patch, action)
|
||||
WHERE id=$1 AND status='pending'`, id, status, patch, action, decidedBy)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
@@ -49,7 +49,7 @@ func TestInterceptDetails(t *testing.T) {
|
||||
var wg sync.WaitGroup
|
||||
for range 8 {
|
||||
wg.Go(func() {
|
||||
ok, err := d.ResolveIntercept(id, "allowed", "allow", "allowed manually")
|
||||
ok, err := d.ResolveIntercept(id, "allowed", "allow", "allowed manually", "human")
|
||||
if err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
@@ -62,7 +62,7 @@ func TestInterceptDetails(t *testing.T) {
|
||||
if wins.Load() != 1 {
|
||||
t.Fatalf("%d decisions won", wins.Load())
|
||||
}
|
||||
ok, err := d.ResolveIntercept(id, "timeout", "deny", "late timeout")
|
||||
ok, err := d.ResolveIntercept(id, "timeout", "deny", "late timeout", "system")
|
||||
if err != nil || ok {
|
||||
t.Fatal("timeout overwrote decision")
|
||||
}
|
||||
@@ -90,7 +90,7 @@ func TestInterceptDetails(t *testing.T) {
|
||||
})
|
||||
t.Run("denied output and timeout allow", func(t *testing.T) {
|
||||
id := create(t, &InterceptAudit{RunID: "run", ToolUseID: "call", ExecutionStatus: "not_started"})
|
||||
if _, err := d.ResolveIntercept(id, "denied", "deny", "denied manually"); err != nil {
|
||||
if _, err := d.ResolveIntercept(id, "denied", "deny", "denied manually", "human"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := d.CompleteIntercept(id, "run", "call", "failed", "Blocked by hook", false); err != nil {
|
||||
@@ -101,7 +101,7 @@ func TestInterceptDetails(t *testing.T) {
|
||||
t.Fatal("denial presented as executed")
|
||||
}
|
||||
id = create(t, &InterceptAudit{RunID: "run2", ToolUseID: "call2", Correlation: "exact", InitialAction: "ask"})
|
||||
if _, err := d.ResolveIntercept(id, "timeout", "allow", "timeout allow"); err != nil {
|
||||
if _, err := d.ResolveIntercept(id, "timeout", "allow", "timeout allow", "system"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := d.CompleteIntercept(id, "run2", "call2", "succeeded", "ok", false); err != nil {
|
||||
@@ -126,6 +126,7 @@ func TestInterceptDetails(t *testing.T) {
|
||||
if legacy {
|
||||
delete(row, "audit")
|
||||
delete(row, "decision_source")
|
||||
delete(row, "decided_by")
|
||||
}
|
||||
archived, _ := json.Marshal([]map[string]any{row})
|
||||
tx, err := d.Begin()
|
||||
@@ -139,13 +140,14 @@ func TestInterceptDetails(t *testing.T) {
|
||||
if err := restoreInterceptRows(tx, archived); err != nil {
|
||||
t.Fatalf("legacy=%t: %v", legacy, err)
|
||||
}
|
||||
var status, source string
|
||||
var status, source, decidedBy string
|
||||
var auditJSON []byte
|
||||
if err := tx.QueryRow(`SELECT status, decision_source, audit FROM intercept_pending WHERE id=$1`, id).Scan(&status, &source, &auditJSON); err != nil {
|
||||
if err := tx.QueryRow(`SELECT status, decision_source, decided_by, audit FROM intercept_pending WHERE id=$1`, id).Scan(&status, &source, &decidedBy, &auditJSON); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if status != "timeout" || source != "model" {
|
||||
t.Fatalf("restored %s/%s", status, source)
|
||||
// The restore itself times the request out: a system decision.
|
||||
if status != "timeout" || source != "model" || decidedBy != "system" {
|
||||
t.Fatalf("restored %s/%s/%s", status, source, decidedBy)
|
||||
}
|
||||
if legacy && len(auditJSON) > 0 {
|
||||
t.Fatal("fabricated legacy audit")
|
||||
|
||||
@@ -1108,6 +1108,10 @@ UPDATE intercept_pending SET decision_source=CASE WHEN rule_id IS NOT NULL THEN
|
||||
UPDATE intercept_pending SET decision_source='model'
|
||||
WHERE decision_source='unknown' AND rule_id IS NULL
|
||||
AND (reason LIKE '[model]%' OR reason LIKE '[模型]%');
|
||||
-- Actor that settled the request: '' (pending, or decided before this column
|
||||
-- existed) | human | rule | model | system. No backfill: readers fall back to
|
||||
-- decision_source for legacy rows instead of rewriting history.
|
||||
ALTER TABLE intercept_pending ADD COLUMN IF NOT EXISTS decided_by TEXT NOT NULL DEFAULT '';
|
||||
|
||||
-- =====================================================================
|
||||
-- L. Finding persistence
|
||||
|
||||
@@ -682,8 +682,13 @@ func restoreInterceptRows(tx *sql.Tx, raw json.RawMessage) error {
|
||||
}
|
||||
row["decision_source"] = source
|
||||
}
|
||||
// Archives predating decided_by lack this NOT NULL column too.
|
||||
if row["decided_by"] == nil {
|
||||
row["decided_by"] = ""
|
||||
}
|
||||
if status, _ := row["status"].(string); status == "pending" {
|
||||
row["status"] = "timeout"
|
||||
row["decided_by"] = "system"
|
||||
row["reason"] = "approval timed out while the task was archived"
|
||||
row["decided_at"] = time.Now().UTC()
|
||||
if audit, ok := row["audit"].(map[string]any); ok {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -637,7 +637,7 @@ func (i *Interceptor) HandleAsk(ctx context.Context, convID int64, dec Decision,
|
||||
case allowed := <-ch:
|
||||
return allowed
|
||||
case <-ctx.Done():
|
||||
_, _ = i.db.ResolveIntercept(pendingID, "denied", "deny", "work cancelled")
|
||||
_, _ = i.db.ResolveIntercept(pendingID, "denied", "deny", "work cancelled", "system")
|
||||
_ = i.db.CompleteIntercept(pendingID, audit.RunID, audit.ToolUseID, "not_executed", "work cancelled before execution", false)
|
||||
return false
|
||||
}
|
||||
@@ -658,7 +658,7 @@ func (i *Interceptor) HandleAsk(ctx context.Context, convID int64, dec Decision,
|
||||
if allowed {
|
||||
action = "allow"
|
||||
}
|
||||
resolved, err := i.db.ResolveIntercept(pendingID, "timeout", action, "approval timed out, applying timeout policy")
|
||||
resolved, err := i.db.ResolveIntercept(pendingID, "timeout", action, "approval timed out, applying timeout policy", "system")
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
@@ -668,7 +668,7 @@ func (i *Interceptor) HandleAsk(ctx context.Context, convID int64, dec Decision,
|
||||
}
|
||||
return allowed
|
||||
case <-ctx.Done():
|
||||
_, _ = i.db.ResolveIntercept(pendingID, "denied", "deny", "work cancelled")
|
||||
_, _ = i.db.ResolveIntercept(pendingID, "denied", "deny", "work cancelled", "system")
|
||||
_ = i.db.CompleteIntercept(pendingID, audit.RunID, audit.ToolUseID, "not_executed", "work cancelled before execution", false)
|
||||
return false
|
||||
}
|
||||
@@ -686,7 +686,7 @@ func (i *Interceptor) Decide(pendingID int64, allowed bool) error {
|
||||
if allowed {
|
||||
action, reason = "allow", "user approved execution"
|
||||
}
|
||||
resolved, err := i.db.ResolveIntercept(pendingID, status, action, reason)
|
||||
resolved, err := i.db.ResolveIntercept(pendingID, status, action, reason, "human")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -79,4 +79,28 @@ type Message struct {
|
||||
// HomeURL is the platform dashboard address (global public_base_url); empty
|
||||
// means no dashboard entry is included.
|
||||
HomeURL string
|
||||
// Limits are the truncation limits to render with; nil means the defaults.
|
||||
// A pointer because the zero value is meaningful (no truncation).
|
||||
Limits *Limits
|
||||
}
|
||||
|
||||
// Limits are the render-time truncation limits of a message, configured
|
||||
// globally (see the server's notify_summary_max_runes / notify_assets_max_shown
|
||||
// settings). In both fields 0 means no truncation; each channel's own length
|
||||
// cap remains the final guard.
|
||||
type Limits struct {
|
||||
// SummaryRunes is how many characters the summary of a single push is
|
||||
// compressed to.
|
||||
SummaryRunes int
|
||||
// AssetsShown is the maximum number of assets listed per vulnerability.
|
||||
AssetsShown int
|
||||
}
|
||||
|
||||
// limits returns the limits to render with, falling back to the defaults when
|
||||
// none were passed in.
|
||||
func (m Message) limits() Limits {
|
||||
if m.Limits == nil {
|
||||
return Limits{SummaryRunes: DefaultMaxSummaryRunes, AssetsShown: DefaultMaxAssetsShown}
|
||||
}
|
||||
return *m.Limits
|
||||
}
|
||||
|
||||
+17
-12
@@ -121,15 +121,20 @@ func feishuSeverityTemplate(severity string) string {
|
||||
}
|
||||
}
|
||||
|
||||
// feishuMaxCardBytes is a conservative cap on card content. Feishu enforces a
|
||||
// size limit on cards and rejects the whole message when exceeded; pick a value
|
||||
// clearly below the official limit to also cover JSON wrapping overhead.
|
||||
const feishuMaxCardBytes = 24000
|
||||
// feishuMaxCardBytes is a conservative cap on card content. Feishu custom bots
|
||||
// reject a request body over 20 KB as a whole (not truncated); pick a value
|
||||
// clearly below that limit to also cover JSON wrapping overhead.
|
||||
const feishuMaxCardBytes = 16000
|
||||
|
||||
// feishuCard builds the interactive card and returns it along with the **number
|
||||
// of items actually written**. kept serves the same purpose as in markdownBody:
|
||||
// only items that actually made it into the card should be marked as sent.
|
||||
func feishuCard(m Message) (map[string]any, int) {
|
||||
lim := m.limits()
|
||||
// budget is the final truncation backstop for one item's text, the same role
|
||||
// TruncateBytes plays in markdownBody. With the summary or asset limit set to
|
||||
// 0 an item has no other bound, and an oversized card loses the whole message.
|
||||
budget := feishuMaxCardBytes - markdownReservedBytes
|
||||
elements := []any{}
|
||||
kept := 0
|
||||
if m.Batch {
|
||||
@@ -137,12 +142,12 @@ func feishuCard(m Message) (map[string]any, int) {
|
||||
// "the remaining N items will continue in the next message", and N must
|
||||
// come from the number of items that actually fit.
|
||||
kept = packItemCount(m.Items, feishuMaxCardBytes, markdownReservedBytes, "", byteSize, func(it Item, idx int) string {
|
||||
return feishuBatchLine(it, idx+1)
|
||||
return feishuBatchLine(it, idx+1, lim)
|
||||
})
|
||||
items := m.Items[:kept]
|
||||
elements = append(elements, feishuMarkdownDiv(markdownBatchIntro(m, items, len(m.Items))))
|
||||
for i, it := range items {
|
||||
elements = append(elements, feishuMarkdownDiv(feishuBatchLine(it, i+1)))
|
||||
elements = append(elements, feishuMarkdownDiv(TruncateBytes(feishuBatchLine(it, i+1, lim), budget)))
|
||||
}
|
||||
if m.HomeURL != "" {
|
||||
elements = append(elements, feishuButton("View all on the platform", m.HomeURL))
|
||||
@@ -150,7 +155,7 @@ func feishuCard(m Message) (map[string]any, int) {
|
||||
} else if len(m.Items) > 0 {
|
||||
kept = 1
|
||||
it := m.Items[0]
|
||||
elements = append(elements, feishuMarkdownDiv(feishuItemLines(it)))
|
||||
elements = append(elements, feishuMarkdownDiv(TruncateBytes(feishuItemLines(it, lim), budget)))
|
||||
if it.DetailURL != "" {
|
||||
elements = append(elements, feishuButton("View details", it.DetailURL))
|
||||
}
|
||||
@@ -189,7 +194,7 @@ func feishuButton(label, url string) map[string]any {
|
||||
// emphasis, so every field coming from outside goes through markdownText
|
||||
// (single-lining + escaping) — otherwise a single vulnerability title could turn
|
||||
// into a clickable external link inside Feishu.
|
||||
func feishuItemLines(it Item) string {
|
||||
func feishuItemLines(it Item, lim Limits) string {
|
||||
out := fmt.Sprintf("**%s · %s**", SeverityLabel(it.Severity), markdownText(it.Title(), 0))
|
||||
if it.IsStatusChange() {
|
||||
out += fmt.Sprintf("\n**Status change**: %s → %s",
|
||||
@@ -198,11 +203,11 @@ func feishuItemLines(it Item) string {
|
||||
if it.VulnClass != "" && it.VulnClass != it.Title() {
|
||||
out += fmt.Sprintf("\n**Type**: %s", markdownText(it.VulnClass, 0))
|
||||
}
|
||||
if a := assetLine(it.Assets, maxAssetsShown); a != "" {
|
||||
if a := assetLine(it.Assets, lim.AssetsShown); a != "" {
|
||||
out += fmt.Sprintf("\n**Assets**: %s", markdownText(a, 0))
|
||||
}
|
||||
if it.Summary != "" {
|
||||
if s := markdownText(it.Summary, maxSummaryRunes); s != "" {
|
||||
if s := markdownText(it.Summary, lim.SummaryRunes); s != "" {
|
||||
out += fmt.Sprintf("\n**Summary**: %s", s)
|
||||
}
|
||||
}
|
||||
@@ -210,9 +215,9 @@ func feishuItemLines(it Item) string {
|
||||
}
|
||||
|
||||
// feishuBatchLine renders one entry of a digest card.
|
||||
func feishuBatchLine(it Item, index int) string {
|
||||
func feishuBatchLine(it Item, index int, lim Limits) string {
|
||||
line := fmt.Sprintf("**%d. %s · %s**", index, SeverityLabel(it.Severity), markdownText(it.Title(), 0))
|
||||
if a := assetLine(it.Assets, maxAssetsShown); a != "" {
|
||||
if a := assetLine(it.Assets, lim.AssetsShown); a != "" {
|
||||
line += " — " + markdownText(a, 0)
|
||||
}
|
||||
return line
|
||||
|
||||
+6
-6
@@ -39,10 +39,10 @@ func htmlBody(m Message, maxRunes int) string {
|
||||
if m.Batch {
|
||||
b.WriteString(htmlBatchIntro(m))
|
||||
for _, it := range m.Items {
|
||||
b.WriteString(htmlItem(it, false))
|
||||
b.WriteString(htmlItem(it, false, m.limits()))
|
||||
}
|
||||
} else if len(m.Items) > 0 {
|
||||
b.WriteString(htmlItem(m.Items[0], true))
|
||||
b.WriteString(htmlItem(m.Items[0], true, m.limits()))
|
||||
}
|
||||
if m.HomeURL != "" {
|
||||
fmt.Fprintf(&b, `<p style="margin:16px 0 0;"><a href="%s" style="color:#1677ff;">View all on the platform</a></p>`, htmlEscapeAttr(m.HomeURL))
|
||||
@@ -79,7 +79,7 @@ func htmlBatchIntro(m Message) string {
|
||||
|
||||
// htmlItem renders a single vulnerability. full=true includes the summary and
|
||||
// backlink (single push); false compresses it into one line (digest list).
|
||||
func htmlItem(it Item, full bool) string {
|
||||
func htmlItem(it Item, full bool, lim Limits) string {
|
||||
color := htmlSeverityColor(it.Severity)
|
||||
var b strings.Builder
|
||||
if full {
|
||||
@@ -92,7 +92,7 @@ func htmlItem(it Item, full bool) string {
|
||||
|
||||
if !full {
|
||||
var extras []string
|
||||
if a := assetLine(it.Assets, maxAssetsShown); a != "" {
|
||||
if a := assetLine(it.Assets, lim.AssetsShown); a != "" {
|
||||
extras = append(extras, htmlEscape(a))
|
||||
}
|
||||
if it.Summary != "" {
|
||||
@@ -112,10 +112,10 @@ func htmlItem(it Item, full bool) string {
|
||||
if it.VulnClass != "" && it.VulnClass != it.Title() {
|
||||
fmt.Fprintf(&b, `<div><b>Type</b>: %s</div>`, htmlEscape(it.VulnClass))
|
||||
}
|
||||
if a := assetLine(it.Assets, maxAssetsShown); a != "" {
|
||||
if a := assetLine(it.Assets, lim.AssetsShown); a != "" {
|
||||
fmt.Fprintf(&b, `<div><b>Assets</b>: %s</div>`, htmlEscape(a))
|
||||
}
|
||||
if s := OneLine(it.Summary, maxSummaryRunes); s != "" {
|
||||
if s := OneLine(it.Summary, lim.SummaryRunes); s != "" {
|
||||
fmt.Fprintf(&b, `<div><b>Summary</b>: %s</div>`, htmlEscape(s))
|
||||
}
|
||||
if it.DetailURL != "" {
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
package notify
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
// This file covers the configurable render limits (Message.Limits). The summary
|
||||
// length and the number of assets listed used to be the fixed constants 120 / 3;
|
||||
// they now come from the caller, with 0 meaning "no truncation" and nil meaning
|
||||
// "the old defaults".
|
||||
|
||||
// limitsItem returns an item whose summary (200 characters) and asset list (8)
|
||||
// both exceed the defaults. It contains no markdown/HTML metacharacters, so
|
||||
// every renderer emits the text verbatim and one set of assertions fits all.
|
||||
func limitsItem() Item {
|
||||
assets := make([]string, 8)
|
||||
for i := range assets {
|
||||
assets[i] = "host" + itoa(i+1)
|
||||
}
|
||||
return Item{Severity: "high", Name: "title", Summary: strings.Repeat("Z", 200), Assets: assets}
|
||||
}
|
||||
|
||||
// feishuContent returns the lark_md text of the card's element idx.
|
||||
func feishuContent(t *testing.T, m Message, idx int) string {
|
||||
t.Helper()
|
||||
card, _ := feishuCard(m)
|
||||
elements, _ := card["elements"].([]any)
|
||||
if idx >= len(elements) {
|
||||
t.Fatalf("card has %d elements, cannot get element %d", len(elements), idx)
|
||||
}
|
||||
div, _ := elements[idx].(map[string]any)
|
||||
text, _ := div["text"].(map[string]any)
|
||||
content, _ := text["content"].(string)
|
||||
return content
|
||||
}
|
||||
|
||||
// limitsRenderings returns the body of every channel family that applies the
|
||||
// limits. For a digest, the Feishu entry is the first item line (element 0 is
|
||||
// the intro).
|
||||
func limitsRenderings(t *testing.T, m Message) map[string]string {
|
||||
t.Helper()
|
||||
md, _ := markdownBody(m, 0)
|
||||
tg, _ := telegramHTML(m)
|
||||
feishuIdx := 0
|
||||
if m.Batch {
|
||||
feishuIdx = 1
|
||||
}
|
||||
return map[string]string{
|
||||
"markdown": md,
|
||||
"feishu": feishuContent(t, m, feishuIdx),
|
||||
"telegram": tg,
|
||||
"email": htmlBody(m, 0),
|
||||
}
|
||||
}
|
||||
|
||||
func TestMessageLimitsApplyToSinglePush(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
lim *Limits
|
||||
must []string
|
||||
wrong []string
|
||||
}{
|
||||
{
|
||||
name: "unset keeps the 120 / 3 defaults",
|
||||
lim: nil,
|
||||
must: []string{strings.Repeat("Z", 119) + ellipsis, "host1, host2, host3 and 5 more"},
|
||||
wrong: []string{strings.Repeat("Z", 120), "host4"},
|
||||
},
|
||||
{
|
||||
name: "zero means no truncation",
|
||||
lim: &Limits{},
|
||||
must: []string{strings.Repeat("Z", 200), "host7, host8"},
|
||||
wrong: []string{ellipsis, " more"},
|
||||
},
|
||||
{
|
||||
name: "custom values",
|
||||
lim: &Limits{SummaryRunes: 10, AssetsShown: 1},
|
||||
must: []string{strings.Repeat("Z", 9) + ellipsis, "host1 and 7 more"},
|
||||
wrong: []string{strings.Repeat("Z", 10), "host2"},
|
||||
},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
m := Message{Items: []Item{limitsItem()}, Limits: tc.lim}
|
||||
for channel, got := range limitsRenderings(t, m) {
|
||||
for _, want := range tc.must {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Errorf("%s: missing %q:\n%s", channel, want, got)
|
||||
}
|
||||
}
|
||||
for _, bad := range tc.wrong {
|
||||
if strings.Contains(got, bad) {
|
||||
t.Errorf("%s: %q should not appear:\n%s", channel, bad, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestMessageLimitsApplyToDigestAssets covers the digest lines, which list the
|
||||
// assets too. The digest summary is not governed by Limits.SummaryRunes: it
|
||||
// stays at its own fixed 60 characters (markdown and email), a digest being a
|
||||
// list of one-liners.
|
||||
func TestMessageLimitsApplyToDigestAssets(t *testing.T) {
|
||||
m := Message{Batch: true, Items: []Item{limitsItem()}}
|
||||
for channel, got := range limitsRenderings(t, m) {
|
||||
if !strings.Contains(got, "host1, host2, host3 and 5 more") {
|
||||
t.Errorf("%s: unset should keep 3 assets:\n%s", channel, got)
|
||||
}
|
||||
}
|
||||
m.Limits = &Limits{}
|
||||
for channel, got := range limitsRenderings(t, m) {
|
||||
if !strings.Contains(got, "host7, host8") || strings.Contains(got, " more") {
|
||||
t.Errorf("%s: 0 should list every asset:\n%s", channel, got)
|
||||
}
|
||||
if strings.Contains(got, strings.Repeat("Z", 60)) {
|
||||
t.Errorf("%s: the digest summary should stay within 60 characters:\n%s", channel, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestFeishuUntruncatedItemStaysWithinBodyLimit is the guard that goes with
|
||||
// "0 = no truncation". Feishu custom bots reject a request body over 20 KB as a
|
||||
// whole, so an unbounded summary or asset list would not arrive shortened — it
|
||||
// would not arrive at all. The body measured here is the real one sent on the
|
||||
// wire, signature included.
|
||||
func TestFeishuUntruncatedItemStaysWithinBodyLimit(t *testing.T) {
|
||||
const feishuBodyLimit = 20000
|
||||
|
||||
huge := limitsItem()
|
||||
huge.DetailURL = "https://artex.local/function/findings/detail?id=42"
|
||||
// 36000 bytes of CJK: also checks that the cut lands on a character boundary.
|
||||
huge.Summary = strings.Repeat("漏洞摘要", 3000)
|
||||
huge.Assets = nil
|
||||
for i := 0; i < 1500; i++ {
|
||||
huge.Assets = append(huge.Assets, "host"+itoa(i+1)+".example.com")
|
||||
}
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
m Message
|
||||
idx int
|
||||
}{
|
||||
{"single push", Message{Items: []Item{huge}, Limits: &Limits{}}, 0},
|
||||
{"digest whose first item is oversized", Message{
|
||||
Batch: true, WindowMinutes: 30, HomeURL: "https://artex.local/function/findings",
|
||||
Items: []Item{huge, huge}, Limits: &Limits{},
|
||||
}, 1},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
size := 0
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
raw, _ := io.ReadAll(r.Body)
|
||||
size = len(raw)
|
||||
_, _ = io.WriteString(w, `{"code":0,"msg":"success"}`)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
kept, err := (feishuChannel{}).Send(context.Background(), map[string]any{"webhook": srv.URL, "secret": "SECtest123"}, tc.m)
|
||||
if err != nil {
|
||||
t.Fatalf("delivery failed: %v", err)
|
||||
}
|
||||
if kept != 1 {
|
||||
t.Fatalf("exactly the first item should be reported as delivered, got %d", kept)
|
||||
}
|
||||
if size == 0 || size > feishuBodyLimit {
|
||||
t.Fatalf("request body of %d bytes, want 1..%d", size, feishuBodyLimit)
|
||||
}
|
||||
content := feishuContent(t, tc.m, tc.idx)
|
||||
if !utf8.ValidString(content) || !strings.HasSuffix(content, ellipsis) {
|
||||
t.Fatalf("the item should be cut on a character boundary and end with an ellipsis, got tail %q", content[len(content)-12:])
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
+16
-16
@@ -9,16 +9,16 @@ import (
|
||||
// (DingTalk, WeCom). Feishu uses card JSON, Telegram uses HTML, and email uses
|
||||
// HTML; each renders in its own adapter.
|
||||
|
||||
// maxAssetsShown is the maximum number of assets listed in a message. A
|
||||
// vulnerability can be anchored to dozens of assets; listing them all would blow
|
||||
// up the message with no informational value — nobody reads the 4th domain
|
||||
// onwards in an IM.
|
||||
const maxAssetsShown = 3
|
||||
// DefaultMaxAssetsShown is the default maximum number of assets listed in a
|
||||
// message (Limits.AssetsShown). A vulnerability can be anchored to dozens of
|
||||
// assets; listing them all would blow up the message with no informational
|
||||
// value — nobody reads the 4th domain onwards in an IM.
|
||||
const DefaultMaxAssetsShown = 3
|
||||
|
||||
// maxSummaryRunes is how many characters the summary is compressed to. An IM
|
||||
// message is a "go look at the details" hint, not the report itself; the full
|
||||
// content lives on the platform.
|
||||
const maxSummaryRunes = 120
|
||||
// DefaultMaxSummaryRunes is the default number of characters the summary is
|
||||
// compressed to (Limits.SummaryRunes). An IM message is a "go look at the
|
||||
// details" hint, not the report itself; the full content lives on the platform.
|
||||
const DefaultMaxSummaryRunes = 120
|
||||
|
||||
// markdownReservedBytes is reserved for the message header (digest line +
|
||||
// severity distribution + possible truncation notice) and footer (platform
|
||||
@@ -114,7 +114,7 @@ func markdownBody(m Message, maxBytes int) (string, int) {
|
||||
return "", 0
|
||||
}
|
||||
var b strings.Builder
|
||||
writeItem(&b, m.Items[0], "", true)
|
||||
writeItem(&b, m.Items[0], "", true, m.limits())
|
||||
// A single message is sent even if oversized (with final truncation as
|
||||
// the backstop): partial information about one vulnerability is better
|
||||
// than sending nothing at all.
|
||||
@@ -127,7 +127,7 @@ func markdownBody(m Message, maxBytes int) (string, int) {
|
||||
}
|
||||
kept := packItemCount(m.Items, maxBytes, markdownReservedBytes, footer, byteSize, func(it Item, idx int) string {
|
||||
var b strings.Builder
|
||||
writeItem(&b, it, fmt.Sprintf("%d. ", idx+1), false)
|
||||
writeItem(&b, it, fmt.Sprintf("%d. ", idx+1), false, m.limits())
|
||||
return b.String()
|
||||
})
|
||||
|
||||
@@ -135,7 +135,7 @@ func markdownBody(m Message, maxBytes int) (string, int) {
|
||||
var b strings.Builder
|
||||
b.WriteString(markdownBatchIntro(m, items, len(m.Items)))
|
||||
for i, it := range items {
|
||||
writeItem(&b, it, fmt.Sprintf("%d. ", i+1), false)
|
||||
writeItem(&b, it, fmt.Sprintf("%d. ", i+1), false, m.limits())
|
||||
}
|
||||
b.WriteString(footer)
|
||||
return TruncateBytes(b.String(), maxBytes), kept
|
||||
@@ -191,13 +191,13 @@ func markdownBatchIntro(m Message, items []Item, total int) string {
|
||||
// markdownText: single-lining + escaping. The backlink is built from the
|
||||
// admin-configured public_base_url, is not untrusted content, and must be a
|
||||
// clickable link, so it is emitted as-is.
|
||||
func writeItem(b *strings.Builder, it Item, prefix string, single bool) {
|
||||
func writeItem(b *strings.Builder, it Item, prefix string, single bool, lim Limits) {
|
||||
line := fmt.Sprintf("%s**%s · %s**", prefix, SeverityLabel(it.Severity), markdownText(it.Title(), 0))
|
||||
if !single {
|
||||
// Digest mode: single-line presentation, with assets and summary
|
||||
// compressed and appended.
|
||||
var extras []string
|
||||
if a := assetLine(it.Assets, maxAssetsShown); a != "" {
|
||||
if a := assetLine(it.Assets, lim.AssetsShown); a != "" {
|
||||
extras = append(extras, markdownText(a, 0))
|
||||
}
|
||||
if it.Summary != "" {
|
||||
@@ -217,11 +217,11 @@ func writeItem(b *strings.Builder, it Item, prefix string, single bool) {
|
||||
if it.VulnClass != "" && it.VulnClass != it.Title() {
|
||||
fmt.Fprintf(b, "**Type**: %s\n", markdownText(it.VulnClass, 0))
|
||||
}
|
||||
if a := assetLine(it.Assets, maxAssetsShown); a != "" {
|
||||
if a := assetLine(it.Assets, lim.AssetsShown); a != "" {
|
||||
fmt.Fprintf(b, "**Assets**: %s\n", markdownText(a, 0))
|
||||
}
|
||||
if it.Summary != "" {
|
||||
if s := markdownText(it.Summary, maxSummaryRunes); s != "" {
|
||||
if s := markdownText(it.Summary, lim.SummaryRunes); s != "" {
|
||||
fmt.Fprintf(b, "**Summary**: %s\n", s)
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -178,7 +178,7 @@ func TestMarkdownEscapesUntrustedContent(t *testing.T) {
|
||||
// writeItem in single mode is the rendering path shared by all three
|
||||
// markdown channels.
|
||||
var b strings.Builder
|
||||
writeItem(&b, tc.item, "", true)
|
||||
writeItem(&b, tc.item, "", true, m.limits())
|
||||
got := b.String()
|
||||
for _, want := range tc.must {
|
||||
if !strings.Contains(got, want) {
|
||||
|
||||
+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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+7
-6
@@ -115,6 +115,7 @@ func telegramEndpoint(cfg map[string]any) (string, error) {
|
||||
// telegramHTML renders the HTML body and returns it together with the number of
|
||||
// items actually written (see Channel.Send).
|
||||
func telegramHTML(m Message) (string, int) {
|
||||
lim := m.limits()
|
||||
var b strings.Builder
|
||||
b.WriteString("<b>" + telegramEscape(markdownTitle(m)) + "</b>\n")
|
||||
if m.Batch {
|
||||
@@ -125,13 +126,13 @@ func telegramHTML(m Message) (string, int) {
|
||||
footer = fmt.Sprintf("\n\n<a href=\"%s\">View all on the platform</a>", telegramEscapeAttr(m.HomeURL))
|
||||
}
|
||||
kept := packItemCount(m.Items, telegramTextLimit, telegramReservedRunes, footer, runeSize, func(it Item, idx int) string {
|
||||
return telegramBatchLine(it, idx+1)
|
||||
return telegramBatchLine(it, idx+1, lim)
|
||||
})
|
||||
items := m.Items[:kept]
|
||||
b.Reset()
|
||||
b.WriteString("<b>" + telegramEscape(telegramBatchTitle(m, items, len(m.Items))) + "</b>")
|
||||
for i, it := range items {
|
||||
b.WriteString("\n" + telegramEscape(telegramBatchLine(it, i+1)))
|
||||
b.WriteString("\n" + telegramEscape(telegramBatchLine(it, i+1, lim)))
|
||||
}
|
||||
b.WriteString(footer)
|
||||
return TruncateHTML(b.String(), telegramTextLimit), kept
|
||||
@@ -147,10 +148,10 @@ func telegramHTML(m Message) (string, int) {
|
||||
if it.VulnClass != "" && it.VulnClass != it.Title() {
|
||||
b.WriteString("\n<b>Type</b>: " + telegramEscape(it.VulnClass))
|
||||
}
|
||||
if a := assetLine(it.Assets, maxAssetsShown); a != "" {
|
||||
if a := assetLine(it.Assets, lim.AssetsShown); a != "" {
|
||||
b.WriteString("\n<b>Assets</b>: " + telegramEscape(a))
|
||||
}
|
||||
if s := OneLine(it.Summary, maxSummaryRunes); s != "" {
|
||||
if s := OneLine(it.Summary, lim.SummaryRunes); s != "" {
|
||||
b.WriteString("\n<b>Summary</b>: " + telegramEscape(s))
|
||||
}
|
||||
if it.DetailURL != "" {
|
||||
@@ -165,8 +166,8 @@ const telegramReservedRunes = 160
|
||||
|
||||
// telegramBatchLine renders one entry of a digest (unescaped; the caller escapes
|
||||
// it uniformly).
|
||||
func telegramBatchLine(it Item, idx int) string {
|
||||
if a := assetLine(it.Assets, maxAssetsShown); a != "" {
|
||||
func telegramBatchLine(it Item, idx int, lim Limits) string {
|
||||
if a := assetLine(it.Assets, lim.AssetsShown); a != "" {
|
||||
return fmt.Sprintf("%d. %s · %s — %s", idx, SeverityLabel(it.Severity), it.Title(), a)
|
||||
}
|
||||
return fmt.Sprintf("%d. %s · %s", idx, SeverityLabel(it.Severity), it.Title())
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
// The cost ledger: profile prices (EUR per 1M tokens) turn token buckets into
|
||||
// EUR on the usage aggregation; unpriced profiles stay nil.
|
||||
func TestCostLedgerEurOnUsage(t *testing.T) {
|
||||
skipWithoutDSN(t)
|
||||
m, err := NewManager(t.TempDir(), "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
|
||||
@@ -22,6 +22,7 @@ import (
|
||||
|
||||
func trafficEvidenceServer(t *testing.T) (*Server, *db.RecordedFinding, func(string, string, string) *httptest.ResponseRecorder) {
|
||||
t.Helper()
|
||||
skipWithoutDSN(t)
|
||||
m, err := NewManager(t.TempDir(), "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+6
-1
@@ -321,7 +321,7 @@ func (s *Server) interceptHistory(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
func interceptFilterParams(q url.Values) (db.InterceptApprovalFilter, error) {
|
||||
filter := db.InterceptApprovalFilter{Status: q.Get("status"), DecisionSource: q.Get("decision_source")}
|
||||
filter := db.InterceptApprovalFilter{Status: q.Get("status"), DecisionSource: q.Get("decision_source"), DecidedBy: q.Get("decided_by")}
|
||||
switch filter.Status {
|
||||
case "", "pending", "allowed", "denied", "timeout":
|
||||
default:
|
||||
@@ -332,6 +332,11 @@ func interceptFilterParams(q url.Values) (db.InterceptApprovalFilter, error) {
|
||||
default:
|
||||
return filter, fmt.Errorf("decision_source must be model, rule or unknown")
|
||||
}
|
||||
switch filter.DecidedBy {
|
||||
case "", "human", "rule", "model", "system":
|
||||
default:
|
||||
return filter, fmt.Errorf("decided_by must be human, rule, model or system")
|
||||
}
|
||||
return filter, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,231 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Autumn-27/artex/db"
|
||||
"github.com/Autumn-27/artex/intercept"
|
||||
)
|
||||
|
||||
// Pure parameter validation: runs without Postgres.
|
||||
func TestInterceptFilterParamsDecidedBy(t *testing.T) {
|
||||
for _, actor := range []string{"", "human", "rule", "model", "system"} {
|
||||
filter, err := interceptFilterParams(url.Values{"decided_by": {actor}})
|
||||
if err != nil || filter.DecidedBy != actor {
|
||||
t.Fatalf("decided_by=%q: %+v err=%v", actor, filter, err)
|
||||
}
|
||||
}
|
||||
for _, actor := range []string{"invalid", "MODEL", "unknown", "human' OR 1=1 --"} {
|
||||
if _, err := interceptFilterParams(url.Values{"decided_by": {actor}}); err == nil {
|
||||
t.Fatalf("decided_by=%q accepted", actor)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Drives every real writer (rule/model log, HTTP decide, timeout, cancellation)
|
||||
// and reads the actor back through the HTTP lists.
|
||||
func TestInterceptDecidedByHTTP(t *testing.T) {
|
||||
dsn, _, err := db.DSN()
|
||||
if err != nil {
|
||||
t.Skip("no test database configured")
|
||||
}
|
||||
d, err := db.Open(dsn)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = d.Close() })
|
||||
m := &Manager{pg: d, interceptor: intercept.New(d)}
|
||||
s := &Server{m: m, jwtKey: []byte("approval-decided-by-test-key")}
|
||||
h := s.Handler()
|
||||
token, err := signJWT(s.jwtKey)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
scope := "approval-http-decided-by"
|
||||
t.Cleanup(func() { _, _ = d.Exec(`DELETE FROM intercept_pending WHERE task_id=$1`, scope) })
|
||||
rule, err := d.CreateInterceptRule("decided-by http fixture", "tool_name", "string", "Bash", "deny", "fixture", 1, false, false, 0, "deny")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _, _ = d.Exec(`DELETE FROM intercept_rules WHERE id=$1`, rule.ID) })
|
||||
do := func(method, path, body string) *httptest.ResponseRecorder {
|
||||
req := httptest.NewRequest(method, path, strings.NewReader(body))
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
return rec
|
||||
}
|
||||
|
||||
// Every step below adds exactly one row to the scope; added returns it.
|
||||
want := map[int64]string{}
|
||||
added := func(actor, status string) int64 {
|
||||
t.Helper()
|
||||
rows, err := d.ListTaskIntercepts(scope)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, r := range rows {
|
||||
if _, known := want[r.ID]; known {
|
||||
continue
|
||||
}
|
||||
if r.Status != status {
|
||||
t.Fatalf("new row %d has status %q, want %q", r.ID, r.Status, status)
|
||||
}
|
||||
want[r.ID] = actor
|
||||
return r.ID
|
||||
}
|
||||
t.Fatalf("no new row for %s/%s", actor, status)
|
||||
return 0
|
||||
}
|
||||
ctx := intercept.WithTaskContext(context.Background(), scope, "test", nil)
|
||||
input := []byte(`{"command":"fixture"}`)
|
||||
modelAsk := intercept.Decision{Action: "ask", Message: db.ModelReasonPrefix + " confirm"}
|
||||
|
||||
m.interceptor.Log(ctx, 0, intercept.Decision{Action: "deny", RuleID: rule.ID, Message: "fixture"}, "Bash", input, "denied")
|
||||
ruleID := added("rule", "denied")
|
||||
m.interceptor.Log(ctx, 0, intercept.Decision{Action: "allow", Message: db.ModelReasonPrefix + " fixture"}, "Bash", input, "allowed")
|
||||
modelID := added("model", "allowed")
|
||||
|
||||
for _, decision := range []string{"allowed", "denied"} {
|
||||
id, err := d.CreateInterceptPending(0, 0, scope, "test", "Bash", input, modelAsk.Message)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if r := do(http.MethodPost, fmt.Sprintf("/api/intercept/pending/%d/decide", id), `{"decision":"`+decision+`"}`); r.Code != 200 {
|
||||
t.Fatalf("decide %s: %d %s", decision, r.Code, r.Body.String())
|
||||
}
|
||||
if got := added("human", decision); got != id {
|
||||
t.Fatalf("decided row %d, want %d", got, id)
|
||||
}
|
||||
}
|
||||
|
||||
timed := modelAsk
|
||||
timed.TimeoutEnabled, timed.TimeoutSeconds, timed.TimeoutAction = true, 1, "deny"
|
||||
if m.interceptor.HandleAsk(ctx, 0, timed, "Bash", input) {
|
||||
t.Fatal("timeout policy deny allowed the call")
|
||||
}
|
||||
added("system", "timeout")
|
||||
|
||||
// Work cancelled: a system refusal recorded with status 'denied', on both
|
||||
// the no-timeout and the timeout-armed wait.
|
||||
cancelled, cancel := context.WithCancel(ctx)
|
||||
cancel()
|
||||
if m.interceptor.HandleAsk(cancelled, 0, modelAsk, "Bash", input) {
|
||||
t.Fatal("cancelled ask allowed the call")
|
||||
}
|
||||
added("system", "denied")
|
||||
timed.TimeoutSeconds = 60
|
||||
if m.interceptor.HandleAsk(cancelled, 0, timed, "Bash", input) {
|
||||
t.Fatal("cancelled timed ask allowed the call")
|
||||
}
|
||||
added("system", "denied")
|
||||
|
||||
if _, err := d.CreateInterceptPending(0, 0, scope, "test", "Bash", input, modelAsk.Message); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
added("", "pending")
|
||||
|
||||
type item struct {
|
||||
ID int64 `json:"id"`
|
||||
Status string `json:"status"`
|
||||
DecidedBy *string `json:"decided_by"`
|
||||
}
|
||||
list := func(path string) (items []item, total int) {
|
||||
t.Helper()
|
||||
rec := do(http.MethodGet, path, "")
|
||||
var body struct {
|
||||
Items []item `json:"items"`
|
||||
Total int `json:"total"`
|
||||
}
|
||||
if rec.Code != 200 || json.Unmarshal(rec.Body.Bytes(), &body) != nil {
|
||||
t.Fatalf("%s: %d %s", path, rec.Code, rec.Body.String())
|
||||
}
|
||||
for _, it := range body.Items {
|
||||
if it.DecidedBy == nil {
|
||||
t.Fatalf("%s: item %d has no decided_by field", path, it.ID)
|
||||
}
|
||||
}
|
||||
return body.Items, body.Total
|
||||
}
|
||||
task := "/api/intercept/task/" + scope
|
||||
items, total := list(task)
|
||||
if total != len(want) || len(items) != len(want) {
|
||||
t.Fatalf("task list: total=%d items=%d want=%d", total, len(items), len(want))
|
||||
}
|
||||
for _, it := range items {
|
||||
if *it.DecidedBy != want[it.ID] {
|
||||
t.Fatalf("item %d (%s) decided_by=%q, want %q", it.ID, it.Status, *it.DecidedBy, want[it.ID])
|
||||
}
|
||||
}
|
||||
|
||||
for _, tc := range []struct {
|
||||
query string
|
||||
total int
|
||||
}{
|
||||
{"?decided_by=model", 1},
|
||||
{"?decided_by=rule", 1},
|
||||
{"?decided_by=human", 2},
|
||||
{"?decided_by=system", 3},
|
||||
{"?status=denied&decided_by=human", 1},
|
||||
{"?status=denied&decided_by=system", 2},
|
||||
{"?status=timeout&decided_by=model", 0},
|
||||
} {
|
||||
items, total := list(task + tc.query)
|
||||
if total != tc.total || len(items) != tc.total {
|
||||
t.Fatalf("%s: total=%d items=%d want=%d", tc.query, total, len(items), tc.total)
|
||||
}
|
||||
for _, it := range items {
|
||||
if !strings.Contains(tc.query, "decided_by="+*it.DecidedBy) {
|
||||
t.Fatalf("%s returned item %d decided by %q", tc.query, it.ID, *it.DecidedBy)
|
||||
}
|
||||
}
|
||||
}
|
||||
if items, _ := list(task + "?decided_by=model"); len(items) != 1 || items[0].ID != modelID {
|
||||
t.Fatalf("decided_by=model returned %+v, want only %d", items, modelID)
|
||||
}
|
||||
// The global history holds other tests' rows too: none of this test's human,
|
||||
// rule or system decisions may surface under decided_by=model.
|
||||
items, _ = list("/api/intercept/history?size=100&decided_by=model")
|
||||
for _, it := range items {
|
||||
if actor, mine := want[it.ID]; *it.DecidedBy != "model" || mine && actor != "model" {
|
||||
t.Fatalf("history decided_by=model returned item %d (%s) decided by %q", it.ID, it.Status, *it.DecidedBy)
|
||||
}
|
||||
}
|
||||
|
||||
for _, base := range []string{"/api/intercept/history", task} {
|
||||
for _, query := range []string{"?decided_by=invalid", "?decided_by=MODEL", "?decided_by=unknown", "?page=1&decided_by=human%27+OR+1%3D1--"} {
|
||||
if rec := do(http.MethodGet, base+query, ""); rec.Code != 400 {
|
||||
t.Fatalf("invalid filter %s%s: %d %s", base, query, rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A row decided before the column existed keeps an empty decided_by; its
|
||||
// decision_source stays in its own field and no decided_by filter returns it.
|
||||
if _, err := d.Exec(`UPDATE intercept_pending SET decided_by='' WHERE id=$1`, ruleID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rec := do(http.MethodGet, fmt.Sprintf("/api/intercept/history/%d", ruleID), "")
|
||||
var legacy struct {
|
||||
DecidedBy *string `json:"decided_by"`
|
||||
DecisionSource string `json:"decision_source"`
|
||||
}
|
||||
if rec.Code != 200 || json.Unmarshal(rec.Body.Bytes(), &legacy) != nil ||
|
||||
legacy.DecidedBy == nil || *legacy.DecidedBy != "" || legacy.DecisionSource != "rule" {
|
||||
t.Fatalf("legacy detail: %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if items, total := list(task + "?decided_by=rule"); total != 0 || len(items) != 0 {
|
||||
t.Fatalf("legacy row matched decided_by=rule: %+v", items)
|
||||
}
|
||||
items, _ = list(task + "?decision_source=rule")
|
||||
if len(items) != 1 || items[0].ID != ruleID || *items[0].DecidedBy != "" {
|
||||
t.Fatalf("legacy row under decision_source=rule: %+v", items)
|
||||
}
|
||||
}
|
||||
@@ -103,6 +103,7 @@ func TestOllamaProviderWiresOpenAIProtocol(t *testing.T) {
|
||||
// loadProfileConfig accepts it, the task chain validates it, the failover pool
|
||||
// includes it, and the connection test completes against a fake Ollama.
|
||||
func TestOllamaKeylessProfileLifecycle(t *testing.T) {
|
||||
skipWithoutDSN(t)
|
||||
m, err := NewManager(t.TempDir(), "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -180,6 +181,7 @@ func TestOllamaKeylessProfileLifecycle(t *testing.T) {
|
||||
// Model listing for an Ollama server works without an API key and tolerates a
|
||||
// base URL with or without the /v1 suffix.
|
||||
func TestOllamaListModels(t *testing.T) {
|
||||
skipWithoutDSN(t)
|
||||
var calls atomic.Value // []string paths
|
||||
fake := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
@@ -18,6 +18,7 @@ import (
|
||||
// assigned raw, so a key saved with surrounding whitespace ran fine in
|
||||
// conversations but 401'd the connection test.
|
||||
func TestLLMTestStoredKeyFallbackIsTrimmed(t *testing.T) {
|
||||
skipWithoutDSN(t)
|
||||
m, err := NewManager(t.TempDir(), "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -97,6 +98,7 @@ func TestLLMTestFallbackMatchesRuntimeNormalization(t *testing.T) {
|
||||
// (the list-view test button) fills provider/model/base_url from the row, so
|
||||
// the key lands on the configured host instead of the canonical default one.
|
||||
func TestLLMTestStoredProfileUsesProfileEndpoint(t *testing.T) {
|
||||
skipWithoutDSN(t)
|
||||
m, err := NewManager(t.TempDir(), "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
|
||||
+43
-5
@@ -5,6 +5,8 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
@@ -25,6 +27,12 @@ const (
|
||||
settingNotifyPublicBaseURL = "notify_public_base_url"
|
||||
// settingNotifyDigestMinutes is the digest-mode period (minutes).
|
||||
settingNotifyDigestMinutes = "notify_digest_interval_min"
|
||||
// settingNotifySummaryMaxRunes is how many characters the summary of a single
|
||||
// push is cut to, and settingNotifyAssetsMaxShown how many asset names a
|
||||
// message lists. 0 means no truncation; unset means the notify package
|
||||
// defaults (120 / 3).
|
||||
settingNotifySummaryMaxRunes = "notify_summary_max_runes"
|
||||
settingNotifyAssetsMaxShown = "notify_assets_max_shown"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -43,6 +51,10 @@ const (
|
||||
notifyFanOutPerTick = 200
|
||||
// notifyDefaultDigestMinutes is the default digest period.
|
||||
notifyDefaultDigestMinutes = 30
|
||||
// notifySummaryMaxRunesCap / notifyAssetsMaxShownCap are the upper bounds the
|
||||
// settings API accepts for the two render limits.
|
||||
notifySummaryMaxRunesCap = 4000
|
||||
notifyAssetsMaxShownCap = 50
|
||||
// notifyUnlimitedBurstPerTick is the per-tick delivery cap when a channel has
|
||||
// no rate limit configured. It exists to prevent "one channel configured as
|
||||
// unlimited + a single scan finding thousands of vulnerabilities" from dragging
|
||||
@@ -223,8 +235,9 @@ func (n *Notifier) stepRealtime(ctx context.Context, ch *db.NotificationChannel,
|
||||
_ = n.pg.FailDeliveries(ctx, deliveryIDs(deliveries), fmt.Sprintf("channel type %q is not registered", ch.Kind))
|
||||
return
|
||||
}
|
||||
limits := notifyLimits(n.pg)
|
||||
for _, dl := range deliveries {
|
||||
msg, err := n.renderSingle(ctx, dl, baseURL)
|
||||
msg, err := n.renderSingle(ctx, dl, baseURL, limits)
|
||||
if err != nil {
|
||||
// A render failure is a local data problem; retrying will not help.
|
||||
_ = n.pg.FailDeliveries(ctx, []int64{dl.ID}, err.Error())
|
||||
@@ -259,7 +272,7 @@ func (n *Notifier) stepDigest(ctx context.Context, ch *db.NotificationChannel, a
|
||||
_ = n.pg.FailDeliveries(ctx, deliveryIDs(deliveries), fmt.Sprintf("channel type %q is not registered", ch.Kind))
|
||||
return
|
||||
}
|
||||
msg, included, err := n.renderBatch(ctx, deliveries, baseURL, int(window.Minutes()))
|
||||
msg, included, err := n.renderBatch(ctx, deliveries, baseURL, int(window.Minutes()), notifyLimits(n.pg))
|
||||
if err != nil {
|
||||
_ = n.pg.FailDeliveries(ctx, deliveryIDs(deliveries), err.Error())
|
||||
return
|
||||
@@ -423,7 +436,7 @@ func (n *Notifier) adapt(ch *db.NotificationChannel) (notify.Channel, map[string
|
||||
}
|
||||
|
||||
// renderSingle renders a single-vulnerability message.
|
||||
func (n *Notifier) renderSingle(ctx context.Context, dl *db.NotificationDelivery, baseURL string) (notify.Message, error) {
|
||||
func (n *Notifier) renderSingle(ctx context.Context, dl *db.NotificationDelivery, baseURL string, limits notify.Limits) (notify.Message, error) {
|
||||
snap, err := parseSnapshot(dl)
|
||||
if err != nil {
|
||||
return notify.Message{}, err
|
||||
@@ -432,7 +445,7 @@ func (n *Notifier) renderSingle(ctx context.Context, dl *db.NotificationDelivery
|
||||
if err != nil {
|
||||
return notify.Message{}, err
|
||||
}
|
||||
return notify.Message{Items: []notify.Item{item}, HomeURL: baseURL}, nil
|
||||
return notify.Message{Items: []notify.Item{item}, HomeURL: baseURL, Limits: &limits}, nil
|
||||
}
|
||||
|
||||
// renderBatch renders a digest message. Snapshots are parsed one by one — a
|
||||
@@ -446,7 +459,7 @@ func (n *Notifier) renderSingle(ctx context.Context, dl *db.NotificationDelivery
|
||||
// entry that should have failed would be marked sent, and a good entry would be
|
||||
// misjudged as undelivered. The broken ones are marked failed explicitly by the
|
||||
// caller, see stepDigest.
|
||||
func (n *Notifier) renderBatch(ctx context.Context, deliveries []*db.NotificationDelivery, baseURL string, windowMinutes int) (notify.Message, []*db.NotificationDelivery, error) {
|
||||
func (n *Notifier) renderBatch(ctx context.Context, deliveries []*db.NotificationDelivery, baseURL string, windowMinutes int, limits notify.Limits) (notify.Message, []*db.NotificationDelivery, error) {
|
||||
items := make([]notify.Item, 0, len(deliveries))
|
||||
included := make([]*db.NotificationDelivery, 0, len(deliveries))
|
||||
for _, dl := range deliveries {
|
||||
@@ -473,6 +486,7 @@ func (n *Notifier) renderBatch(ctx context.Context, deliveries []*db.Notificatio
|
||||
Batch: true,
|
||||
WindowMinutes: windowMinutes,
|
||||
HomeURL: baseURL,
|
||||
Limits: &limits,
|
||||
}, included, nil
|
||||
}
|
||||
|
||||
@@ -582,6 +596,30 @@ func (n *Notifier) digestInterval() time.Duration {
|
||||
return time.Duration(m) * time.Minute
|
||||
}
|
||||
|
||||
// notifyLimits reads the summary/asset render limits from the settings.
|
||||
func notifyLimits(pg *db.DB) notify.Limits {
|
||||
summary, sOK, _ := pg.GetSetting(settingNotifySummaryMaxRunes)
|
||||
assets, aOK, _ := pg.GetSetting(settingNotifyAssetsMaxShown)
|
||||
return notify.Limits{
|
||||
SummaryRunes: notifyLimitValue(summary, sOK, notify.DefaultMaxSummaryRunes, notifySummaryMaxRunesCap),
|
||||
AssetsShown: notifyLimitValue(assets, aOK, notify.DefaultMaxAssetsShown, notifyAssetsMaxShownCap),
|
||||
}
|
||||
}
|
||||
|
||||
// notifyLimitValue resolves one stored render limit: def when the key is unset,
|
||||
// unparseable, or outside [0, max]. 0 is a valid value (no truncation), which is
|
||||
// why "unset" is carried by ok and not by the number.
|
||||
func notifyLimitValue(v string, ok bool, def, max int) int {
|
||||
if !ok {
|
||||
return def
|
||||
}
|
||||
n, err := strconv.Atoi(strings.TrimSpace(v))
|
||||
if err != nil || n < 0 || n > max {
|
||||
return def
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// parseSnapshot parses the snapshot of the event a delivery corresponds to.
|
||||
func parseSnapshot(dl *db.NotificationDelivery) (notify.Snapshot, error) {
|
||||
var snap notify.Snapshot
|
||||
|
||||
@@ -447,6 +447,8 @@ func (s *Server) notifyTestChannel(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
msg := notifyTestMessage(s.notifierBaseURL(pg))
|
||||
limits := notifyLimits(pg)
|
||||
msg.Limits = &limits
|
||||
start := time.Now()
|
||||
// The test message is a single entry, so the delivered count is not needed
|
||||
// here (for a single message the channel length limit falls back to
|
||||
|
||||
@@ -700,6 +700,108 @@ func TestNotifyMetaAndSettingsRoundTrip(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestNotifyLimitSettingsRoundTrip covers the two render-limit keys through the
|
||||
// settings API and the settings table: unset reads as the 120 / 3 defaults, a
|
||||
// stored 0 reads back as 0 (no truncation) and not as the default, and
|
||||
// out-of-range values are rejected without touching the stored value.
|
||||
func TestNotifyLimitSettingsRoundTrip(t *testing.T) {
|
||||
f := newNotifyFixture(t)
|
||||
clear := func() {
|
||||
f.pg.Exec(`DELETE FROM settings WHERE key IN ($1,$2)`, settingNotifySummaryMaxRunes, settingNotifyAssetsMaxShown)
|
||||
}
|
||||
clear()
|
||||
t.Cleanup(clear)
|
||||
expect := func(step string, summary, assets int) {
|
||||
t.Helper()
|
||||
payload := f.s.settingsPayload()
|
||||
if payload["notify_summary_max_runes"] != summary || payload["notify_assets_max_shown"] != assets {
|
||||
t.Fatalf("%s: settings payload should report %d / %d, got %v / %v", step, summary, assets,
|
||||
payload["notify_summary_max_runes"], payload["notify_assets_max_shown"])
|
||||
}
|
||||
if got := notifyLimits(f.pg); got != (notify.Limits{SummaryRunes: summary, AssetsShown: assets}) {
|
||||
t.Fatalf("%s: the delivery engine should read %d / %d, got %+v", step, summary, assets, got)
|
||||
}
|
||||
}
|
||||
expect("unset", notify.DefaultMaxSummaryRunes, notify.DefaultMaxAssetsShown)
|
||||
|
||||
for _, tc := range []struct{ summary, assets int }{{500, 10}, {0, 0}, {4000, 50}} {
|
||||
body := fmt.Sprintf(`{"notify_summary_max_runes":%d,"notify_assets_max_shown":%d}`, tc.summary, tc.assets)
|
||||
if r := f.request("PUT", "/api/settings", body); r.Code != 200 {
|
||||
t.Fatalf("%s failed %d: %s", body, r.Code, r.Body)
|
||||
}
|
||||
expect(body, tc.summary, tc.assets)
|
||||
for key, want := range map[string]int{settingNotifySummaryMaxRunes: tc.summary, settingNotifyAssetsMaxShown: tc.assets} {
|
||||
if v, ok, err := f.pg.GetSetting(key); err != nil || !ok || v != strconv.Itoa(want) {
|
||||
t.Fatalf("%s: settings row %s should hold %d, got %q (ok=%v err=%v)", body, key, want, v, ok, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for _, body := range []string{
|
||||
`{"notify_summary_max_runes":-1}`,
|
||||
`{"notify_summary_max_runes":4001}`,
|
||||
`{"notify_assets_max_shown":-1}`,
|
||||
`{"notify_assets_max_shown":51}`,
|
||||
} {
|
||||
if r := f.request("PUT", "/api/settings", body); r.Code != 400 {
|
||||
t.Errorf("%s should return 400, got %d", body, r.Code)
|
||||
}
|
||||
}
|
||||
expect("after rejected writes", 4000, 50)
|
||||
}
|
||||
|
||||
// TestNotifySummaryLimitReachesDelivery is the end-to-end evidence that the
|
||||
// stored limit reaches the message actually sent: with the key unset the summary
|
||||
// is cut at 120 characters as before, and once it is set to 0 the same summary
|
||||
// goes out whole.
|
||||
func TestNotifySummaryLimitReachesDelivery(t *testing.T) {
|
||||
f := newNotifyFixture(t)
|
||||
clear := func() { f.pg.Exec(`DELETE FROM settings WHERE key=$1`, settingNotifySummaryMaxRunes) }
|
||||
clear()
|
||||
t.Cleanup(clear)
|
||||
hook := newFakeWebhook(t)
|
||||
chID := f.createChannel(t, map[string]any{
|
||||
"name": "summary limit",
|
||||
"kind": notify.KindDingTalk,
|
||||
"config": map[string]any{"webhook": hook.URL},
|
||||
})
|
||||
summary := strings.Repeat("Z", 300)
|
||||
record := func(name string) {
|
||||
t.Helper()
|
||||
if _, err := f.s.evidenceStore().Record(context.Background(), db.RecordFindingInput{
|
||||
TaskID: f.taskID,
|
||||
ExplorationID: f.expID,
|
||||
Worker: "test",
|
||||
VulnClass: name,
|
||||
Name: name,
|
||||
Severity: "high",
|
||||
Summary: summary,
|
||||
Evidence: "poc",
|
||||
}, nil); err != nil {
|
||||
t.Fatalf("failed to record finding: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
record("summary limit unset")
|
||||
f.deliver(t, chID, "")
|
||||
text := markdownText(t, hook.last(t))
|
||||
if !strings.Contains(text, strings.Repeat("Z", 119)+"…") || strings.Contains(text, strings.Repeat("Z", 120)) {
|
||||
t.Fatalf("with the key unset the summary should be cut at 120 characters:\n%s", text)
|
||||
}
|
||||
|
||||
if r := f.request("PUT", "/api/settings", `{"notify_summary_max_runes":0}`); r.Code != 200 {
|
||||
t.Fatalf("failed to write settings %d: %s", r.Code, r.Body)
|
||||
}
|
||||
record("summary limit zero")
|
||||
f.deliver(t, chID, "")
|
||||
if hook.count() != 2 {
|
||||
t.Fatalf("expected 2 messages sent, got %d", hook.count())
|
||||
}
|
||||
if text := markdownText(t, hook.last(t)); !strings.Contains(text, summary) {
|
||||
t.Fatalf("with the limit at 0 the whole summary should be sent:\n%s", text)
|
||||
}
|
||||
}
|
||||
|
||||
// TestNotifyDeepLinkUsesPublicBaseURL covers back-link building: when
|
||||
// public_base_url is configured, single messages must use the ActionCard with a
|
||||
// button, and the link must point to the vulnerability detail page.
|
||||
@@ -1008,3 +1110,30 @@ func TestNotifyTickBudgetFitsWithinLease(t *testing.T) {
|
||||
worst, notifyLease, notifyMaxSendsPerChannelPerTick, notifySendTimeout)
|
||||
}
|
||||
}
|
||||
|
||||
// TestNotifyLimitValue pins how a stored render limit is resolved. The case that
|
||||
// matters is "0": it is a real value (no truncation) and must not be read as
|
||||
// "unset", while anything unparseable or outside the bounds falls back to the
|
||||
// default instead of reaching the renderer.
|
||||
func TestNotifyLimitValue(t *testing.T) {
|
||||
const def, max = 120, 4000
|
||||
cases := []struct {
|
||||
v string
|
||||
ok bool
|
||||
want int
|
||||
}{
|
||||
{"", false, def},
|
||||
{"0", true, 0},
|
||||
{" 500 ", true, 500},
|
||||
{"4000", true, max},
|
||||
{"4001", true, def},
|
||||
{"-1", true, def},
|
||||
{"abc", true, def},
|
||||
{"", true, def},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
if got := notifyLimitValue(tc.v, tc.ok, def, max); got != tc.want {
|
||||
t.Errorf("notifyLimitValue(%q, %v) = %d, want %d", tc.v, tc.ok, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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" {
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
// open-goal fingerprint is unchanged since the last completed round AND active
|
||||
// work exists (open or running intents). Every other combination plans.
|
||||
func TestPlannerQuietSkipEdge(t *testing.T) {
|
||||
skipWithoutDSN(t)
|
||||
m, err := NewManager(t.TempDir(), "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -78,6 +79,7 @@ func TestPlannerQuietSkipEdge(t *testing.T) {
|
||||
// The quiet-mode switch is editable mid-run (#86): PATCHing plan_quiet_mode
|
||||
// updates both the stored row and the live Task the engine reads per round.
|
||||
func TestPlannerQuietModeToggleMidRun(t *testing.T) {
|
||||
skipWithoutDSN(t)
|
||||
m, err := NewManager(t.TempDir(), "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
|
||||
@@ -3454,6 +3454,7 @@ func (s *Server) settingsPayload() map[string]any {
|
||||
if concLimit == 0 {
|
||||
concLimit = defaultConcurrencyLimit // when off, still echo a sane default to the UI
|
||||
}
|
||||
limits := notifyLimits(s.m.pg)
|
||||
return map[string]any{
|
||||
"traffic_capture": s.m.TrafficEnabled(),
|
||||
"agent_traffic_binding": s.m.pg.GetBool(settingAgentTrafficBinding, false),
|
||||
@@ -3483,6 +3484,9 @@ func (s *Server) settingsPayload() map[string]any {
|
||||
"notify_enabled": s.m.pg.GetBool(settingNotifyEnabled, true),
|
||||
"notify_public_base_url": notifyPublicBaseURL(s.m.pg),
|
||||
"notify_digest_interval_min": notifyDigestIntervalMin(s.m.pg),
|
||||
// Render limits of IM messages; 0 = no truncation.
|
||||
"notify_summary_max_runes": limits.SummaryRunes,
|
||||
"notify_assets_max_shown": limits.AssetsShown,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3555,6 +3559,9 @@ func (s *Server) putSettings(w http.ResponseWriter, r *http.Request) {
|
||||
NotifyEnabled *bool `json:"notify_enabled"`
|
||||
NotifyBaseURL *string `json:"notify_public_base_url"`
|
||||
NotifyDigestMins *int `json:"notify_digest_interval_min"`
|
||||
// Render limits of IM messages: summary characters (0-4000) and asset names listed (0-50); 0 = no truncation.
|
||||
NotifySummaryMaxRunes *int `json:"notify_summary_max_runes"`
|
||||
NotifyAssetsMaxShown *int `json:"notify_assets_max_shown"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeErr(w, 400, err.Error())
|
||||
@@ -3610,6 +3617,26 @@ func (s *Server) putSettings(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
}
|
||||
if req.NotifySummaryMaxRunes != nil {
|
||||
if *req.NotifySummaryMaxRunes < 0 || *req.NotifySummaryMaxRunes > notifySummaryMaxRunesCap {
|
||||
writeErr(w, 400, fmt.Sprintf("summary limit must be between 0 and %d characters (0 = no truncation)", notifySummaryMaxRunesCap))
|
||||
return
|
||||
}
|
||||
if err := s.m.pg.SetSetting(settingNotifySummaryMaxRunes, strconv.Itoa(*req.NotifySummaryMaxRunes)); err != nil {
|
||||
writeErr(w, 500, err.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
if req.NotifyAssetsMaxShown != nil {
|
||||
if *req.NotifyAssetsMaxShown < 0 || *req.NotifyAssetsMaxShown > notifyAssetsMaxShownCap {
|
||||
writeErr(w, 400, fmt.Sprintf("asset limit must be between 0 and %d (0 = no truncation)", notifyAssetsMaxShownCap))
|
||||
return
|
||||
}
|
||||
if err := s.m.pg.SetSetting(settingNotifyAssetsMaxShown, strconv.Itoa(*req.NotifyAssetsMaxShown)); err != nil {
|
||||
writeErr(w, 500, err.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
if req.Workers != nil {
|
||||
if err := s.m.SetWorkers(*req.Workers); err != nil {
|
||||
writeErr(w, 400, err.Error())
|
||||
|
||||
@@ -32,3 +32,13 @@ func TestMain(m *testing.M) {
|
||||
defer conn.Exec(`SELECT pg_advisory_unlock(7337741002)`) //nolint:errcheck
|
||||
os.Exit(m.Run())
|
||||
}
|
||||
|
||||
// skipWithoutDSN skips the test when neither ARTEX_PG_DSN nor a config file
|
||||
// supplies a database, so fixtures that t.Fatal on a NewManager error skip
|
||||
// instead of failing. A configured database that cannot be reached still fails.
|
||||
func skipWithoutDSN(t *testing.T) {
|
||||
t.Helper()
|
||||
if _, _, err := db.DSN(); err != nil {
|
||||
t.Skipf("no database config (%v) — skipping", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
// trafficEvidenceServer but without the finding plumbing.
|
||||
func ingestServer(t *testing.T) (*Server, func(method, path, body string) *httptest.ResponseRecorder) {
|
||||
t.Helper()
|
||||
skipWithoutDSN(t)
|
||||
m, err := NewManager(t.TempDir(), "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
|
||||
@@ -108,6 +108,23 @@ function StatusBadge({ status }: { status: string }) {
|
||||
return <Badge variant={variant}>{labels[status] ?? status}</Badge>;
|
||||
}
|
||||
|
||||
const decidedByLabels: Record<string, string> = { human: "Human", rule: "Rule", model: "Model", system: "System" };
|
||||
|
||||
// decided_by is shown exactly as stored, so a row always sits under the
|
||||
// "Decided by" filter option matching its badge. A decided row without an actor
|
||||
// predates the column: it reads "Legacy" and matches no option.
|
||||
function DecidedByBadge({ row }: { row: InterceptApprovalRow }) {
|
||||
if (row.status === "pending") return <span className="text-muted-foreground">—</span>;
|
||||
if (!row.decided_by) {
|
||||
return (
|
||||
<Badge variant="outline" className="text-muted-foreground">
|
||||
Legacy
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
return <Badge variant="outline">{decidedByLabels[row.decided_by] ?? row.decided_by}</Badge>;
|
||||
}
|
||||
|
||||
function MatchCell({ row, showReason = true }: { row: InterceptApprovalRow; showReason?: boolean }) {
|
||||
const reason = row.reason?.replace(/^\[model\]\s*/, "");
|
||||
return (
|
||||
@@ -383,6 +400,10 @@ export function ApprovalDetail({
|
||||
<dd>{fmtTime(row.created_at)}</dd>
|
||||
<dt className="text-muted-foreground">Decided at</dt>
|
||||
<dd>{fmtTime(current.decided_at)}</dd>
|
||||
<dt className="text-muted-foreground">Decided by</dt>
|
||||
<dd>
|
||||
<DecidedByBadge row={current} />
|
||||
</dd>
|
||||
{audit?.rule_name ? (
|
||||
<>
|
||||
<dt className="text-muted-foreground">Rule snapshot</dt>
|
||||
@@ -527,10 +548,10 @@ function subscribeColumns(onChange: () => void) {
|
||||
}
|
||||
function visibleColumnCount() {
|
||||
const matches = approvalBreakpoints.map((query) => window.matchMedia(query).matches);
|
||||
return 3 + Number(matches[0]) + Number(matches[1]) + 2 * Number(matches[2]) + 2 * Number(matches[3]);
|
||||
return 3 + Number(matches[0]) + Number(matches[1]) + 3 * Number(matches[2]) + 2 * Number(matches[3]);
|
||||
}
|
||||
function serverColumnCount() {
|
||||
return 9;
|
||||
return 10;
|
||||
}
|
||||
|
||||
function ApprovalTable({
|
||||
@@ -570,6 +591,7 @@ function ApprovalTable({
|
||||
<TableHead className="hidden lg:table-cell">Matched rule</TableHead>
|
||||
<TableHead className="hidden xl:table-cell">Arguments</TableHead>
|
||||
<TableHead className="w-24">Status</TableHead>
|
||||
<TableHead className="hidden w-24 lg:table-cell">Decided by</TableHead>
|
||||
<TableHead className="hidden w-36 lg:table-cell">Requested at</TableHead>
|
||||
<TableHead className="hidden w-36 xl:table-cell">Decided at</TableHead>
|
||||
</TableRow>
|
||||
@@ -623,6 +645,9 @@ function ApprovalTable({
|
||||
<TableCell>
|
||||
<StatusBadge status={row.status} />
|
||||
</TableCell>
|
||||
<TableCell className="hidden lg:table-cell">
|
||||
<DecidedByBadge row={row} />
|
||||
</TableCell>
|
||||
<TableCell className="hidden text-muted-foreground text-xs lg:table-cell">
|
||||
{fmtTime(row.created_at)}
|
||||
</TableCell>
|
||||
@@ -663,7 +688,7 @@ export function ApprovalRecords({ taskId }: { taskId?: string }) {
|
||||
const request = React.useRef(0);
|
||||
const decisionLock = React.useRef(false);
|
||||
const filterID = React.useId();
|
||||
const filtered = Boolean(filter.status || filter.decision_source);
|
||||
const filtered = Boolean(filter.status || filter.decision_source || filter.decided_by);
|
||||
|
||||
// A task can stay mounted while the user switches between task details.
|
||||
// Reset the cursor so the new scope always starts at its newest records.
|
||||
@@ -749,7 +774,9 @@ export function ApprovalRecords({ taskId }: { taskId?: string }) {
|
||||
// Optimistically drop the decided item from the independent pending queue
|
||||
// for instant feedback; the re-fetch below reconciles with server truth.
|
||||
setRows((prev) =>
|
||||
prev.map((row) => (row.id === id ? { ...row, status: decision, decided_at: new Date().toISOString() } : row)),
|
||||
prev.map((row) =>
|
||||
row.id === id ? { ...row, status: decision, decided_by: "human", decided_at: new Date().toISOString() } : row,
|
||||
),
|
||||
);
|
||||
setPendingRows((prev) => prev.filter((row) => row.id !== id));
|
||||
setRevision((v) => v + 1);
|
||||
@@ -840,6 +867,31 @@ export function ApprovalRecords({ taskId }: { taskId?: string }) {
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
<Field className="w-full sm:w-40">
|
||||
<FieldLabel htmlFor={`${filterID}-decided-by`}>Decided by</FieldLabel>
|
||||
<Select
|
||||
value={filter.decided_by ?? "all"}
|
||||
onValueChange={(value) =>
|
||||
changeFilter({
|
||||
...filter,
|
||||
decided_by: value === "all" ? undefined : (value as InterceptApprovalFilter["decided_by"]),
|
||||
})
|
||||
}
|
||||
>
|
||||
<SelectTrigger id={`${filterID}-decided-by`}>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
<SelectItem value="all">All actors</SelectItem>
|
||||
<SelectItem value="model">AI-decided</SelectItem>
|
||||
<SelectItem value="human">Human</SelectItem>
|
||||
<SelectItem value="rule">Rule</SelectItem>
|
||||
<SelectItem value="system">System</SelectItem>
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
{filtered ? (
|
||||
<Button variant="ghost" size="sm" onClick={() => changeFilter({})}>
|
||||
Clear filters
|
||||
@@ -885,7 +937,7 @@ export function ApprovalRecords({ taskId }: { taskId?: string }) {
|
||||
<EmptyTitle>{filtered ? "No approval records match the filters" : "No approval records"}</EmptyTitle>
|
||||
<EmptyDescription>
|
||||
{filtered
|
||||
? "Adjust the approval status or decision source, or clear the filters to see all records."
|
||||
? "Adjust the approval status, decision source or deciding actor, or clear the filters to see all records."
|
||||
: "Records will appear here once a rule or model makes an approval decision."}
|
||||
</EmptyDescription>
|
||||
</EmptyHeader>
|
||||
|
||||
@@ -231,6 +231,7 @@ function interceptPageQuery(page: number, size: number, filter: InterceptApprova
|
||||
const query = new URLSearchParams({ page: String(page), size: String(size) });
|
||||
if (filter.status) query.set("status", filter.status);
|
||||
if (filter.decision_source) query.set("decision_source", filter.decision_source);
|
||||
if (filter.decided_by) query.set("decided_by", filter.decided_by);
|
||||
return query.toString();
|
||||
}
|
||||
|
||||
|
||||
@@ -3496,6 +3496,7 @@ interceptHistory.unshift({
|
||||
tool_input: { path: "reports/summary.md", content: "# Check summary\n\nThis round of verification is complete; existing evidence and follow-up suggestions are organized." },
|
||||
status: "allowed",
|
||||
decision_source: "model",
|
||||
decided_by: "model",
|
||||
reason: "[model] Write the existing check conclusions into a local report without modifying business data.",
|
||||
created_at: T("2026-07-26T08:00:00Z"),
|
||||
decided_at: T("2026-07-26T08:00:02Z"),
|
||||
|
||||
@@ -2431,16 +2431,19 @@ function route(m: string, path: string, seg: string[], q: URLSearchParams, b: Re
|
||||
const decisionSource = q.get("decision_source") || "";
|
||||
if (status && !["pending", "allowed", "denied", "timeout"].includes(status)) throw new Error("invalid approval status");
|
||||
if (decisionSource && !["model", "rule", "unknown"].includes(decisionSource)) throw new Error("invalid decision source");
|
||||
const decidedBy = q.get("decided_by") || "";
|
||||
if (decidedBy && !["human", "rule", "model", "system"].includes(decidedBy)) throw new Error("invalid decided_by");
|
||||
const filtered = mockInterceptHistory.filter((row) => {
|
||||
const source =
|
||||
row.decision_source || (row.rule_id ? "rule" : row.reason?.startsWith("[model]") ? "model" : "unknown");
|
||||
return (
|
||||
(seg[1] !== "task" || row.task_id === decodeURIComponent(seg[2])) &&
|
||||
(!status || row.status === status) &&
|
||||
(!decisionSource || source === decisionSource)
|
||||
(!decisionSource || source === decisionSource) &&
|
||||
(!decidedBy || row.decided_by === decidedBy)
|
||||
);
|
||||
});
|
||||
if (!q.has("page") && !q.has("size") && !status && !decisionSource)
|
||||
if (!q.has("page") && !q.has("size") && !status && !decisionSource && !decidedBy)
|
||||
return { items: filtered, total: filtered.length };
|
||||
const page = Math.max(1, Number(q.get("page")) || 1);
|
||||
const size = Math.min(100, Math.max(1, Number(q.get("size")) || 20));
|
||||
|
||||
@@ -1347,6 +1347,9 @@ export interface AssetInterceptRule {
|
||||
|
||||
export interface InterceptPending {
|
||||
decision_source?: "rule" | "model" | "unknown" | "";
|
||||
// Actor that settled the request, exactly as stored: "" while pending and on
|
||||
// rows decided before the column existed (shown as "Legacy").
|
||||
decided_by?: "human" | "rule" | "model" | "system" | "";
|
||||
id: number;
|
||||
rule_id?: number;
|
||||
conversation_id?: number;
|
||||
@@ -1391,6 +1394,7 @@ export interface JudgeUsage {
|
||||
export interface InterceptApprovalFilter {
|
||||
status?: InterceptPending["status"];
|
||||
decision_source?: "rule" | "model" | "unknown";
|
||||
decided_by?: "human" | "rule" | "model" | "system";
|
||||
}
|
||||
|
||||
// InterceptApprovalRow enriches InterceptPending with conversation/task and rule context.
|
||||
|
||||
Reference in New Issue
Block a user