Compare commits

...
Author SHA1 Message Date
BaaRai a422a071f6 server: skip Postgres-backed tests when no database is configured
Without ARTEX_PG_DSN and without a config file, go test ./server/ failed
44 tests with "no database config found" instead of skipping them. The
tests go through NewManager and t.Fatal on its error: 35 through the
shared fixture trafficEvidenceServer (directly, or through
verificationFixture and newNotifyFixture), 2 through ingestServer, and 7
call NewManager themselves. The 7 already carry a skip on m.pg == nil,
but it sits after the t.Fatal and is never reached.

Add skipWithoutDSN, which calls db.DSN() and skips with the message the
db package and assembly_test.go already use ("no database config (%v) —
skipping"), and call it before NewManager at those 9 sites.

Nothing changes when a database is configured: db.DSN() succeeds, the
helper returns, and a NewManager error (for example an unreachable
Postgres) still fails the test.
2026-11-10 16:28:26 +01:00
BaaRai 3b6eb2fd2c Merge pull request #172 from BaaRai/feat/notify-configurable-limits
notify: make the summary and asset limits configurable
2026-11-10 16:16:30 +01:00
BaaRai 2ab1046746 Merge pull request #171 from BaaRai/fix/companies-overflow-count
db: report hidden asset count in malformed-ip warning suffix
2026-11-10 16:16:24 +01:00
BaaRai 809436e111 notify: make the summary and asset limits configurable
The IM renderers cut the summary of a single push at 120 characters and
listed at most 3 assets, as fixed constants. Both are now global settings
in the settings table, read by the delivery engine when it renders:

  notify_summary_max_runes   0-4000, default 120
  notify_assets_max_shown    0-50,   default 3

0 means no truncation and an unset key keeps 120 / 3. PUT /api/settings
answers 400 for a value outside the bounds. The limits travel on
notify.Message, so the notify package still does not touch the database.

With no truncation, nothing bounded a single item in a Feishu card, and
Feishu rejects an oversized request as a whole. feishuCard now truncates
an item's text as the final backstop, the role TruncateBytes plays in
markdownBody for DingTalk and WeCom. feishuMaxCardBytes goes from 24000
to 16000: Feishu custom bots cap the request body at 20 KB, which the old
value exceeded.

Refs #168.
2026-11-10 16:05:31 +01:00
BaaRai c84f124cb4 db: report hidden asset count in malformed-ip warning suffix
malformedIPAssetWarning appended "and N more" with N = total, the number
of offending assets, instead of the number left out of the sample. With 8
bad rows and 5 named it read "... and 8 more"; it now reads "... and 3
more". Same bug as the one fixed in notify/render.go.

Move the wording into malformedIPAssetWarningText so it can be tested
without Postgres, and add a test for the 8-of-5 and 5-of-5 cases.
2026-11-10 15:49:41 +01:00
BaaRai cf0b0c1002 Merge pull request #170 from BaaRai/fix/notify-overflow-count
notify: report hidden asset count in overflow suffix
2026-11-10 15:44:46 +01:00
BaaRai b17a0ea796 notify: report hidden asset count in overflow suffix
assetLine appended "and N more" with N = len(assets), the total, instead
of the number of assets left out. With 5 assets and a limit of 2 it read
"a, b and 5 more"; it now reads "a, b and 3 more".

Fix the test that pinned the old value and add an 8-assets/limit-3 case.
2026-11-10 15:07:37 +01:00
BaaRai 59c3702a5e Merge pull request #169 from BaaRai/fix/verification-gzip-body
fix(verification): decode gzip/deflate bodies before the S1 quote check
2026-11-10 14:31:37 +01:00
BaaRai 05df40eecb test(verification): lock that an empty bound body never satisfies a quote
A response captured with Content-Encoding: gzip and no body (HEAD, 304) is
decoded to an empty body without error. Pin that behaviour, and pin that an
empty body, compressed or not, matches no non-empty quote through
readBoundResponse and quoteInBodies, so it can never lead to a verified
finding.
2026-11-10 12:52:56 +01:00
BaaRai f4f0df128a fix(verification): decode gzip/deflate response bodies before the S1 quote check
Compressed HTML responses are stored as captured, so the S1 receipt check saw them as binary and returned not_assessed. readBoundResponse now undoes the Content-Encoding declared in the snapshot's response head before the text/binary detection and the quote search.

decodeEvidenceBody handles identity and a single layer of gzip or deflate (zlib-wrapped or bare), bounded by verificationReadCap on the decoded side. Any other coding, stacked codings, or a stream that fails to decode is an error, which the caller reports as not_assessed (evidence body unreadable). The stored evidence bytes are unchanged.
2026-11-10 12:33:40 +01:00
BaaRai 00f5d8c103 Merge pull request #130 from kiserufetch/fix/list-task-credentials-empty-required
fix(tools): list_task_credentials emits invalid "required": [""] and breaks workers on strict LLM providers
2026-11-10 10:43:29 +01:00
BaaRai a44af3ca2b fix(tools): harden obj against blank required names and heal seeded list_task_credentials rows
The previous commit stops list_task_credentials from building its schema
with obj(map[string]any{}, ""). Two gaps remained:

- obj still emitted whatever it was given, so the same typo on another tool
  would again produce "required": [""]. obj now drops names that are blank
  after strings.TrimSpace and omits the "required" key when nothing is left.

- Tool schemas are cached in the tools table and SeedTool is insert-only, so
  the code fix only reached fresh installs. Existing databases kept the
  malformed row and strict providers kept rejecting every worker run.
  healListTaskCredentialsSchema runs at startup next to the other tool
  seeds and removes the "required" key from that one row, only when it is
  exactly [""]:

    UPDATE tools SET schema = schema - 'required', updated_at=now()
    WHERE key='list_task_credentials' AND schema->'required' = '[""]'::jsonb

  It is idempotent (a healed row no longer matches), needs no settings flag,
  and leaves the description, bindings, enabled flag and every other tool
  untouched. tool_schema_refresh_v7 is not bumped.

Tests: TestObjDropsBlankRequired and
TestListTaskCredentialsSchemaHasNoBlankRequired (agent),
TestHealListTaskCredentialsSchema (server, Postgres).
2026-11-10 09:46:49 +01:00
BaaRai 81eab87d16 Merge pull request #166 from BaaRai/feat/untrusted-boundary-toggle
feat: enable the untrusted-output boundary with ARTEX_UNTRUSTED_BOUNDARY
2026-11-09 23:51:52 +01:00
BaaRai 06453409ea chore: pass ARTEX_UNTRUSTED_BOUNDARY to the artex service
Without this line the toggle set in .env never reached the container under
docker compose. The default is empty, which keeps the boundary off.
2026-11-09 23:44:38 +01:00
BaaRai c73e2872a3 feat: enable the untrusted-output boundary with ARTEX_UNTRUSTED_BOUNDARY
Norma v0.4.6 frames tool output between nonce-tagged markers, so the model
reads it as data. ARTEX passes that option from the environment: 1, true or
on enable it (case and surrounding spaces ignored); unset or anything else
keeps it off, which is the default. Nothing changes unless the variable is set.

The option is wired into the four agentcore.Options builders that set
ToolOutputDir: worker, planner, chat and the main agent. .env.example
documents the variable, commented out, with a note to test it on a local
target before enabling it in production.

Not included: docker-compose.yml does not pass the variable to the artex
service, so it has no effect under compose until it is listed there.
2026-11-09 23:42:49 +01:00
BaaRai a83f494d03 Merge pull request #165 from BaaRai/feat/intercept-decided-by
feat: record which actor decided an intercept approval
2026-11-09 23:28:17 +01:00
BaaRai 52ea3f65c6 fix: rename the model filter option to AI-decided
The option filters decided_by = model, which includes denials the model judge
made, so "AI-approved" was inaccurate. Approved-by-AI is the combination with
status allowed.
2026-11-09 23:17:08 +01:00
BaaRai 2218480eba feat: show and filter the decided_by actor in the approval records
The approval list and detail return decided_by exactly as stored, and the
decided_by filter matches that same value. Rows decided before the column
existed show "Legacy" and match no decided_by filter; their decision_source
stays in its own column and nothing is rewritten.

UI: a "Decided by" column (Human, Rule, Model, System) and a matching filter
beside the decision-source filter, including "AI-approved only" for model.
Types and the API query carry decided_by the same way decision_source does.
2026-11-09 22:35:09 +01:00
BaaRai 8f50b81792 feat: record which actor decided an intercept approval
Add intercept_pending.decided_by (human | rule | model | system; empty while
pending or for rows decided before the column existed). Approval semantics are
unchanged: only the actor is recorded.

- rule-matched and model-judged rows written by CreateDecidedIntercept record
  rule or model;
- ResolveIntercept takes the actor explicitly and rejects any other value
  before touching the database;
- Decide (human allow/deny) records human; timeouts and work-cancelled
  denials record system. Work-cancelled is a system refusal, not a human one.
- decided_by is exposed on the pending and approval records and filterable
  with decided_by=human|rule|model|system (anything else is a 400).
- Legacy rows show decision_source as their actor when decided_by is empty.
  The filter uses the stored column only, so decided_by=model cannot match
  older human decisions or timeouts.
- Archive restore fills decided_by for archives that predate the column, and
  marks a restored pending row as system.
2026-11-09 22:17:56 +01:00
BaaRai 3e75f1f954 Merge pull request #164 from BaaRai/feat/retest-verification
feat: a completed retest updates the verification state
2026-11-09 21:56:06 +01:00
BaaRai 8634ddbf23 feat: a completed retest updates the verification state
A completed retest with a reproduced or fixed verdict is an independent proof,
so it sets verification_status to verified with a note naming the retest, its
verdict and the start of its summary. The upgrade applies only from unverified,
not_assessed or blocked: a verified finding keeps its POE level and note.

Inconclusive, failed and stopped retests never change verification_status; their
verdict stays in the retest history. The human status is untouched by this path.
The existing fixed path (status set to Fixed with notifications) is unchanged.

Tests cover reproduced, fixed (status stays Fixed), inconclusive on a verified
finding, failed and stopped, and the upgrade from not_assessed and blocked.
2026-11-09 21:47:40 +01:00
BaaRai ec89070b8e Merge pull request #163 from BaaRai/feat/verification-ui
feat: verification badge, filter and note in the findings UI
2026-11-09 21:21:42 +01:00
kiserufetchandClaude Opus 4.8 a66c716c0a fix(tools): drop empty required entry from list_task_credentials schema
list_task_credentials takes no parameters but built its JSON Schema with
obj(map[string]any{}, ""), emitting {"type":"object","required":[""],
"properties":{}}. The required array contains an empty string, which strict
function-call validators reject. Observed with DeepSeek through an
OpenAI-compatible gateway (format "openai"):

  openai: status 400 invalid_tool_spec -
  "function.parameters.required must be an array of non-empty strings"

Because the tool is in the worker toolset, every worker run fails before the
first tool call (terminal state model_error); the planner and main agent are
unaffected since they don't load it. Anthropic/OpenAI accept the malformed
schema silently, so it only surfaces on stricter providers.

Pass no required args so obj omits the required key entirely:
{"type":"object","properties":{}}.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-11-02 09:05:03 +03:00
53 changed files with 1780 additions and 93 deletions
+5
View File
@@ -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
+3
View File
@@ -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()
+3
View File
@@ -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
+3
View File
@@ -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
+41
View File
@@ -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
View File
@@ -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
}
+1 -1
View File
@@ -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
+19
View File
@@ -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
}
+59
View File
@@ -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)
}
}
}
+3
View File
@@ -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
View File
@@ -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.
+18
View File
@@ -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
View File
@@ -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.
+107
View File
@@ -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, &note); 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
View File
@@ -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 ")
+169
View File
@@ -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
View File
@@ -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
}
+10 -8
View File
@@ -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")
+4
View File
@@ -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
+5
View File
@@ -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 {
+1
View File
@@ -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"
+4 -4
View File
@@ -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
}
+24
View File
@@ -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
View File
@@ -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
View File
@@ -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 != "" {
+183
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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,
+7 -5
View File
@@ -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
View File
@@ -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())
+1
View File
@@ -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)
+1
View File
@@ -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)
+65 -1
View File
@@ -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]
+285
View File
@@ -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
View File
@@ -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
}
+231
View File
@@ -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)
}
}
+2
View File
@@ -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")
+2
View File
@@ -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
View File
@@ -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
+2
View File
@@ -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
+129
View File
@@ -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)
}
}
}
+14
View File
@@ -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" {
+2
View File
@@ -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)
+27
View File
@@ -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())
+10
View File
@@ -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)
}
}
+90
View File
@@ -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
+1
View File
@@ -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)
+57 -5
View File
@@ -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>
+1
View File
@@ -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();
}
+1
View File
@@ -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"),
+5 -2
View File
@@ -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));
+4
View File
@@ -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.