Compare commits

...
Author SHA1 Message Date
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
BaaRai 1810a82187 feat: verification status badge, filter and note in the findings UI
Show the automated verification outcome next to the human triage status:
a badge on the findings list and detail page ("Verified L3", "Unverified",
"Blocked", "Not assessed", "False positive"), a verification-state filter
next to the status filter (persisted with the other list preferences), and
the verification note on the detail page when the state is not verified.

API: the findings list, groups, asset tree and export accept a
verification_status filter. Without it nothing changes.

Not included: a verified count in the statistics.
2026-11-09 21:04:12 +01:00
BaaRai 34cbbeadb7 Merge pull request #162 from BaaRai/feat/verified-findings
feat: verified findings backend (S1)
2026-11-09 20:50:13 +01:00
BaaRai a1426bbd35 test: use an 8+ rune quote in the unreadable-store case
The quote x7 is now rejected as too short before the store is read, so the
test never reached the not_assessed path. Use a quote long enough to pass
the minimum; the store removal is unchanged.
2026-11-09 20:43:25 +01:00
BaaRai 0a740f0894 fix: ignore evidence quotes shorter than 8 runes
A two-character quote such as "x7" can exist in any body and proves
nothing. Normalized quotes under 8 runes are now ignored; if none remain
the finding is unverified with the note "evidence quote too short".
Check order is unchanged: binding, POE, quote presence, then body.
2026-11-09 20:27:42 +01:00
BaaRai f938ba00d9 feat: verified findings backend (S1)
Add a machine-checked verification state to findings, separate from the
human disposition (status). A finding is verified only when a proof or
verification traffic body is bound, the agent declares POE L3 or higher,
and every evidence quote appears verbatim, after whitespace
normalization, in one of those response bodies.

- schema: poe_level, verification_status, verification_note on findings
  (idempotent ADD COLUMN IF NOT EXISTS, no CHECK, no backfill).
- db: SetFindingVerification validates status and POE 0-4; the three
  fields are scanned with the finding rows and exposed in the DTO.
- update_finding_report: optional poe_level and evidence_quotes. The
  report is always saved; verification only decides the status. Read
  failures give not_assessed, never unverified.
- report agent guidance: declare POE and exact quotes from the bound body.

Not in this change: UI badges and filters, the blocked and false_positive
transitions, and the stored default report prompt for existing databases
(the guidance is appended at run time).
2026-11-09 20:21:27 +01:00
BaaRai f5db962eeb Merge pull request #161 from BaaRai/chore/norma-v0.4.6
chore: norma v0.4.6 (boundary stays disabled)
2026-11-09 20:06:48 +01:00
BaaRai 62571c9b34 chore: keep untrusted-output boundary off until isolated smoke test
Revert the four UntrustedOutputBoundary: true lines. The bump to Norma
v0.4.6 stays; the boundary is available there but remains disabled until
an isolated smoke run is done (no throwaway database, cheap profile or
test key yet).
2026-11-09 19:24:24 +01:00
BaaRai 4fe80b860c chore: bump norma to v0.4.6 and enable untrusted-output boundary
Bump the Norma replace to v0.4.6, which adds the opt-in
UntrustedOutputBoundary option: tool results are framed as data between
nonce-tagged markers, and the output cap leaves room for the framing.

Enable the option in the four agentcore.Options literals that set
ToolOutputDir: chat, mainagent, planner and worker.
2026-11-09 19:19:42 +01:00
BaaRai fbe78395d5 Merge pull request #160 from BaaRai/chore/norma-v0.4.5
deps: bump Norma replace to v0.4.5
2026-11-09 15:54:39 +01:00
BaaRai 6ce50b7d13 deps: bump Norma replace to v0.4.5 2026-11-09 15:42:49 +01:00
BaaRai 9c2b56001d Merge pull request #159 from BaaRai/fix/intercept-decision-source-prefix
fix(intercept): judge decisions were stored as unknown (prefix case mismatch)
2026-11-09 12:31:38 +01:00
BaaRai 9481b8e962 fix(intercept): judge decisions were stored as unknown (prefix case mismatch)
Since e233c02 the judge writer prefixes its reason with "[model]" while
interceptSource, the approvalDecisionSource SQL fallback and the archive
restore checked "[Model]", so model-decided rows were inserted with
decision_source='unknown' and missed by the decision_source=model filter.

- db: add ModelReasonPrefix ("[model]") and isModelReason, which also
  accepts the pre-e233c02 "[模型]" prefix; readers compare
  case-insensitively (lower(reason) LIKE in SQL).
- intercept: the writer builds its reason through JudgeReason, using the
  shared constant.
- schema.sql: idempotent backfill of 'unknown' rows without a rule whose
  reason carries either prefix.
- tests: un-skip TestInterceptFilterHTTP (it failed only because of
  this), make the db filter fixture use the constant, and add a
  Postgres-free test pinning the writer output to interceptSource.

Fixes #156
2026-11-09 12:22:46 +01:00
BaaRai f90e8d2bcb Merge pull request #158 from BaaRai/docs/openai-responses-base-url
docs: openai-responses base URL suffix
2026-11-09 12:04:10 +01:00
BaaRai cab0a10c6f docs: openai-responses base URL suffix 2026-11-09 11:53:01 +01:00
BaaRai 180538e463 Merge pull request #157 from BaaRai/docs/anthropic-base-url
docs: Anthropic base URL must not end in /v1
2026-11-09 11:37:27 +01:00
36 changed files with 1373 additions and 92 deletions
+1 -1
View File
@@ -32,7 +32,7 @@ jobs:
- run: go build ./...
- run: go vet ./...
# Skipped tests, tracked in issue #9:
# - TestInterceptFilterHTTP, TestGraphOverviewExpandsAssociatedCompanyScope,
# - TestGraphOverviewExpandsAssociatedCompanyScope,
# TestCaptureApprovalLifecycle: fail on unmodified upstream code with a
# fresh database.
# - TestSideUsageRecordedOnceOnConsumerCancellation: concurrency/cancellation
+1 -1
View File
@@ -10,7 +10,7 @@ All notable changes to this project are documented in this file. The format is b
### Docs
- **LLM base URL note in the README**: for the Anthropic format, enter the host without a `/v1` suffix (the provider appends `/v1/messages`, so a `/v1` base becomes `/v1/v1/messages`); OpenAI-format providers append `/chat/completions`, so their base does end in `/v1`. Follow-up to #154.
- **LLM base URL note in the README**: for the Anthropic format, enter the host without a `/v1` suffix (the provider appends `/v1/messages`, so a `/v1` base becomes `/v1/v1/messages`); OpenAI-format providers append `/chat/completions` (or `/responses` for the `openai-responses` format), so their base does end in `/v1`. Follow-up to #154.
## [0.5.1] - 2026-11-05
+1 -1
View File
@@ -256,7 +256,7 @@ CGO_ENABLED=0 go build -tags embedui -o artex ./cmd/artex
**LLM**: `export ANTHROPIC_API_KEY=sk-...` (or `OPENAI_API_KEY`); you can also fill it in on the UI's "LLM Configuration" page — which can add a local Ollama server as a keyless provider (format "Ollama", base URL default `http://127.0.0.1:11434/v1`, models loaded from the server).
Optional: `ARTEX_LLM_PROVIDER` (anthropic | openai | openai-responses | ollama) / `ARTEX_LLM_MODEL` / `ARTEX_LLM_BASE_URL` / `ARTEX_LLM_PROXY`.
**Base URL**: for the Anthropic format, enter the host **without** a `/v1` suffix (e.g. `https://api.anthropic.com`) — the provider appends `/v1/messages` itself, so a base ending in `/v1` becomes `https://api.anthropic.com/v1/v1/messages`. OpenAI-format providers append `/chat/completions`, so their base **does** end in `/v1` (e.g. `https://api.openai.com/v1`).
**Base URL**: for the Anthropic format, enter the host **without** a `/v1` suffix (e.g. `https://api.anthropic.com`) — the provider appends `/v1/messages` itself, so a base ending in `/v1` becomes `https://api.anthropic.com/v1/v1/messages`. OpenAI-format providers append `/chat/completions` (or `/responses` for the `openai-responses` format), so their base **does** end in `/v1` (e.g. `https://api.openai.com/v1`).
**Concurrency**: the number of work agents per task is configured in "System Settings" (default 3).
+8
View File
@@ -76,6 +76,9 @@ func findingWorkflowTools(agentKey string, tools []actool.CoreTool) ([]actool.Co
if has["update_finding_report"] && has["bind_finding_traffic"] && has["get_finding_traffic"] {
guidance += "\n\n**Auto-link traffic before reporting (enabled)**: you are responsible for verifying and binding traffic for the vulnerability that triggered this run, then writing the report. First obtain the unambiguous finding_id and finding_node_id from the report_finding return JSON or get_task_node_detail / list_task_findings. Read the vulnerability details, the corresponding intent's execution records, and the existing evidence list, preferring the real IDs handed off by the reporter. If this verification is HTTP and the traffic tools are available, use traffic_search to filter candidates, then traffic_get to verify item by item that the request/response truly supports the vulnerability; domains and times are only for filtering and do not prove attribution. Bind the confirmed evidence in reproduction order via bind_finding_traffic(finding_id, traffic_refs), choosing baseline / proof / verification / supporting and stating the purpose. Operate only on this vulnerability — do not recreate vulnerabilities or re-probe the target. After a successful bind, call get_finding_traffic again for the latest version, read the bodies you need, then pass the version you actually read as evidence_version to update_finding_report (whose finding_id parameter still takes finding_node_id). Do not re-append existing bindings. For TCP, no-capture, unavailable tools, or no exact match, skip auto-binding, write the report from textual/command evidence with the reason stated, and never guess to force traffic in. Do not claim success when binding fails; keep the existing evidence and explain in the report why nothing was bound."
}
if has["update_finding_report"] {
guidance += findingVerificationGuidance
}
if guidance != "" || has["get_finding_traffic"] || has["update_finding_report"] {
guidance += findingIDGuidance
}
@@ -126,3 +129,8 @@ func (t *ToolSet) findingRefsFromHint(hintID int64, explicit []db.TrafficRef) ([
}
return db.NormalizeTrafficRefs(append(append([]db.TrafficRef{}, explicit...), payload.Refs...))
}
// findingVerificationGuidance tells the report writer how to declare proof
// (S1). The server downgrades any finding whose declaration does not hold up,
// so the agent must never state a level or quote it cannot read in the bound body.
const findingVerificationGuidance = "\n\n**Verification (S1)**: when you call update_finding_report, also declare poe_level (0-4) and evidence_quotes. poe_level 1 = error, timing or response variation; 2 = structural manipulation (boolean, UNION, parser difference); 3 = actual data extracted; 4 = critical impact (admin credentials, code execution). L3 is the minimum for a verified finding. evidence_quotes must be exact lines copied from a bound proof or verification response body (read it with get_finding_traffic first), never from memory or from the report text; the server checks each quote verbatim after whitespace normalization. If you cannot quote the proof, omit the quotes: the finding then stays unverified, which is acceptable. Do not raise poe_level to get a verified status."
+4
View File
@@ -0,0 +1,4 @@
package db
// InterceptSource exposes interceptSource to external (db_test) tests.
var InterceptSource = interceptSource
+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)
}
})
}
}
+50
View File
@@ -0,0 +1,50 @@
package db
import (
"fmt"
)
// Machine-checked verification state of a finding (S1). Kept apart from
// findings.status, which is the human disposition. Values are validated here
// (server-side), not by a CHECK constraint, like the other findings columns.
const (
VerificationVerified = "verified"
VerificationUnverified = "unverified"
VerificationBlocked = "blocked"
VerificationNotAssessed = "not_assessed"
VerificationFalsePositive = "false_positive"
)
// ValidVerificationStatus reports whether s is a known verification state.
func ValidVerificationStatus(s string) bool {
switch s {
case VerificationVerified, VerificationUnverified, VerificationBlocked,
VerificationNotAssessed, VerificationFalsePositive:
return true
}
return false
}
// SetFindingVerification records the verification state, POE level and note on
// the standalone findings row. It does not touch status, report or evidence.
func (d *DB) SetFindingVerification(findingID int64, status string, poe int, note string) error {
if !ValidVerificationStatus(status) {
return fmt.Errorf("invalid verification status %q", status)
}
if poe < 0 || poe > 4 {
return fmt.Errorf("invalid POE level %d (want 0-4)", poe)
}
res, err := d.Exec(`UPDATE findings SET verification_status=$2, poe_level=$3, verification_note=$4 WHERE id=$1`,
findingID, status, poe, note)
if err != nil {
return err
}
n, err := res.RowsAffected()
if err != nil {
return err
}
if n == 0 {
return ErrFindingNotFound
}
return nil
}
+29 -23
View File
@@ -20,20 +20,23 @@ type DBFinding struct {
ReportEvidenceVersion int64
TrafficBindings []FindingTrafficBinding // populated only for export
ID int64
TaskID *int64
NodeID *int64
VulnClass string
Name string // vulnerability name (readable title); when empty the frontend falls back to VulnClass
Severity string
Summary string
Evidence string
Worker string
AssetIDs []int64
Status string
Report string // detailed report (Markdown); only populated by GetFinding, not by list queries
CreatedAt time.Time
TaskDescription string // populated via LEFT JOIN on tasks
ID int64
TaskID *int64
NodeID *int64
VulnClass string
Name string // vulnerability name (readable title); when empty the frontend falls back to VulnClass
Severity string
Summary string
Evidence string
Worker string
AssetIDs []int64
Status string
PoELevel int // proof-of-exploitation level 0-4 (S1); 0 = none declared
VerificationStatus string // verified / unverified / blocked / not_assessed / false_positive
VerificationNote string // why the status was reached; empty when verified
Report string // detailed report (Markdown); only populated by GetFinding, not by list queries
CreatedAt time.Time
TaskDescription string // populated via LEFT JOIN on tasks
}
// Finding triage states (findings.status).
@@ -105,6 +108,7 @@ func (d *DB) AddFinding(taskID, nodeID int64, vulnclass, name, severity, summary
const findingSelectCols = `f.id, f.task_id, f.node_id, f.vulnclass, COALESCE(f.name, ''), f.severity, f.summary,
f.evidence, f.worker, f.asset_ids, COALESCE(f.status, 'pending'), f.created_at,
COALESCE(t.description, '') AS task_description, f.evidence_version, f.report_evidence_version,
f.poe_level, COALESCE(f.verification_status, 'unverified'), COALESCE(f.verification_note, ''),
(SELECT count(*) FROM finding_traffic_bindings b WHERE b.finding_id=f.id)`
// scanFindings materializes rows selected via findingSelectCols.
@@ -118,7 +122,7 @@ func scanFindings(rows interface {
f := &DBFinding{}
var aidsJSON string
if err := rows.Scan(&f.ID, &f.TaskID, &f.NodeID, &f.VulnClass, &f.Name, &f.Severity,
&f.Summary, &f.Evidence, &f.Worker, &aidsJSON, &f.Status, &f.CreatedAt, &f.TaskDescription, &f.EvidenceVersion, &f.ReportEvidenceVersion, &f.TrafficCount); err != nil {
&f.Summary, &f.Evidence, &f.Worker, &aidsJSON, &f.Status, &f.CreatedAt, &f.TaskDescription, &f.EvidenceVersion, &f.ReportEvidenceVersion, &f.PoELevel, &f.VerificationStatus, &f.VerificationNote, &f.TrafficCount); err != nil {
return nil, err
}
_ = json.Unmarshal([]byte(aidsJSON), &f.AssetIDs)
@@ -150,12 +154,13 @@ func (d *DB) ListFindings(limit int) ([]*DBFinding, error) {
// filter on that column". Sort is "severity" (severity desc, then newest) or
// anything else (newest first).
type FindingFilter struct {
Severity string // high | medium | low
Status string // pending | false_positive | ignored | resolved
VulnClass string
TaskID string // task id (in string form; empty/invalid = do not filter by task)
Query string // fuzzy search keyword over name/class/summary/evidence/report body
Sort string // "severity" | "time"
Severity string // high | medium | low
Status string // pending | false_positive | ignored | resolved
VerificationStatus string // verified | unverified | blocked | not_assessed | false_positive
VulnClass string
TaskID string // task id (in string form; empty/invalid = do not filter by task)
Query string // fuzzy search keyword over name/class/summary/evidence/report body
Sort string // "severity" | "time"
// AssetScope is a node key of the asset tree (a:<id> / c:<id> / r:<domain> /
// __none__); selecting a node means selecting its whole subtree.
// Empty = do not filter by asset.
@@ -188,6 +193,7 @@ func (f FindingFilter) where() (string, []any) {
}
add("severity", f.Severity)
add("status", f.Status)
add("verification_status", f.VerificationStatus)
add("vulnclass", f.VulnClass)
// task_id is a bigint column, compared as an integer (it cannot go through
// the text add above); empty/invalid values are ignored.
@@ -519,7 +525,7 @@ func (d *DB) ListFindingsForExport(f FindingFilter, ids []int64) ([]*DBFinding,
var aidsJSON string
if err := rows.Scan(&f.ID, &f.TaskID, &f.NodeID, &f.VulnClass, &f.Name, &f.Severity,
&f.Summary, &f.Evidence, &f.Worker, &aidsJSON, &f.Status, &f.CreatedAt,
&f.TaskDescription, &f.EvidenceVersion, &f.ReportEvidenceVersion, &f.TrafficCount, &f.Report); err != nil {
&f.TaskDescription, &f.EvidenceVersion, &f.ReportEvidenceVersion, &f.PoELevel, &f.VerificationStatus, &f.VerificationNote, &f.TrafficCount, &f.Report); err != nil {
return nil, err
}
_ = json.Unmarshal([]byte(aidsJSON), &f.AssetIDs)
@@ -649,7 +655,7 @@ func (d *DB) GetFinding(id int64) (*DBFinding, error) {
WHERE f.id = $1`, id).Scan(
&f.ID, &f.TaskID, &f.NodeID, &f.VulnClass, &f.Name, &f.Severity,
&f.Summary, &f.Evidence, &f.Worker, &aidsJSON, &f.Status, &f.CreatedAt,
&f.TaskDescription, &f.EvidenceVersion, &f.ReportEvidenceVersion, &f.TrafficCount, &f.Report)
&f.TaskDescription, &f.EvidenceVersion, &f.ReportEvidenceVersion, &f.PoELevel, &f.VerificationStatus, &f.VerificationNote, &f.TrafficCount, &f.Report)
if err == sql.ErrNoRows {
return nil, nil
}
+45 -11
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,7 +39,8 @@ type InterceptPending struct {
ToolInput json.RawMessage `json:"tool_input"`
Status string `json:"status"`
DecisionSource string `json:"decision_source"`
Reason string `json:"reason"` // rule message or model decision reason (prefixed with [Model])
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,17 +230,37 @@ 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,
)
}
// ModelReasonPrefix starts every reason written by the LLM fallback judge
// (intercept.JudgeReason); readers derive decision_source='model' from it.
const ModelReasonPrefix = "[model]"
// legacyModelReasonPrefix is what the judge wrote before the UI translation (e233c02).
const legacyModelReasonPrefix = "[模型]"
// isModelReason reports whether reason was written by the model judge. The
// comparison ignores case: readers used to expect "[Model]" while the writer
// produced "[model]", so stored rows may carry either spelling.
func isModelReason(reason string) bool {
return strings.HasPrefix(strings.ToLower(reason), ModelReasonPrefix) ||
strings.HasPrefix(reason, legacyModelReasonPrefix)
}
// Keep legacy rows without decision_source consistent with their displayed source.
// Mirrors isModelReason; PostgreSQL LIKE is case-sensitive and '[' is literal.
const approvalDecisionSource = `COALESCE(NULLIF(ip.decision_source,''), CASE
WHEN ip.rule_id IS NOT NULL THEN 'rule'
WHEN ip.reason LIKE '[Model]%' THEN 'model' ELSE 'unknown' END)`
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`
@@ -248,7 +277,7 @@ func interceptSource(ruleID int64, reason string) string {
if ruleID != 0 {
return "rule"
}
if strings.HasPrefix(reason, "[Model]") {
if isModelReason(reason) {
return "model"
}
return "unknown"
@@ -284,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.
@@ -342,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")
+1 -1
View File
@@ -38,7 +38,7 @@ func TestInterceptApprovalFilters(t *testing.T) {
ruleID = rule.ID
}
if source == "model" {
reason = "[Model] fixture"
reason = ModelReasonPrefix + " fixture"
}
id, err := d.CreateDecidedIntercept(ruleID, 0, task, "test", "Bash", []byte(`{"command":"fixture"}`), status, reason)
if err != nil {
+37
View File
@@ -0,0 +1,37 @@
package db_test
import (
"testing"
"github.com/Autumn-27/artex/db"
"github.com/Autumn-27/artex/intercept"
)
// The judge writer (package intercept) and the decision_source readers (package
// db) must agree on the reason prefix; this pins them together without Postgres.
func TestInterceptSourceMatchesJudgeWriter(t *testing.T) {
for _, tc := range []struct{ action, message string }{
{"allow", ""},
{"deny", "destructive command outside the workspace"},
{"ask", db.ModelReasonPrefix + " already prefixed"},
} {
reason := intercept.JudgeReason(tc.action, tc.message)
if got := db.InterceptSource(0, reason); got != "model" {
t.Fatalf("JudgeReason(%q, %q) = %q classified as %q, want model", tc.action, tc.message, reason, got)
}
}
}
func TestInterceptSourceLegacyModelPrefix(t *testing.T) {
for _, reason := range []string{"[模型] 允许", "[Model] fixture"} {
if got := db.InterceptSource(0, reason); got != "model" {
t.Fatalf("%q classified as %q, want model", reason, got)
}
}
if got := db.InterceptSource(0, "legacy reason"); got != "unknown" {
t.Fatalf("plain reason classified as %q, want unknown", got)
}
if got := db.InterceptSource(7, "[model] rule wins"); got != "rule" {
t.Fatalf("rule row classified as %q, want rule", got)
}
}
+17 -1
View File
@@ -1088,7 +1088,7 @@ CREATE TABLE IF NOT EXISTS intercept_pending (
tool_input JSONB NOT NULL DEFAULT '{}',
status TEXT NOT NULL DEFAULT 'pending'
CHECK (status IN ('pending', 'allowed', 'denied', 'timeout')),
-- Decision reason: the rule message when a rule hits; the model's short reason (with the [Model] prefix) for an LLM fallback decision.
-- Decision reason: the rule message when a rule hits; the model's short reason (with the [model] prefix) for an LLM fallback decision.
reason TEXT NOT NULL DEFAULT '',
decided_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
@@ -1102,6 +1102,16 @@ ALTER TABLE intercept_pending ADD COLUMN IF NOT EXISTS audit JSONB;
ALTER TABLE intercept_pending ADD COLUMN IF NOT EXISTS decision_source TEXT NOT NULL DEFAULT '';
UPDATE intercept_pending SET decision_source=CASE WHEN rule_id IS NOT NULL THEN 'rule'
WHEN reason LIKE '[Model]%' THEN 'model' ELSE 'unknown' END WHERE decision_source='';
-- Judge decisions were stored as 'unknown' because the readers expected a capitalized
-- prefix while the judge wrote '[model]' (and '[模型]' before e233c02). Idempotent:
-- once corrected no row matches the WHERE clause again.
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
@@ -1171,6 +1181,12 @@ CREATE TRIGGER trg_conversation_retest_delete BEFORE DELETE ON conversations
ALTER TABLE findings ADD COLUMN IF NOT EXISTS evidence_version BIGINT NOT NULL DEFAULT 0;
ALTER TABLE findings ADD COLUMN IF NOT EXISTS report_evidence_version BIGINT NOT NULL DEFAULT 0;
-- Machine-checked proof state (S1). Separate from status (human disposition). Existing rows stay
-- unverified / POE 0: history is never auto-verified. Values validated server-side, no CHECK.
-- verification_status: verified / unverified / blocked / not_assessed / false_positive.
ALTER TABLE findings ADD COLUMN IF NOT EXISTS poe_level SMALLINT NOT NULL DEFAULT 0;
ALTER TABLE findings ADD COLUMN IF NOT EXISTS verification_status TEXT NOT NULL DEFAULT 'unverified';
ALTER TABLE findings ADD COLUMN IF NOT EXISTS verification_note TEXT NOT NULL DEFAULT '';
CREATE TABLE IF NOT EXISTS traffic_evidence_snapshots (
id TEXT PRIMARY KEY,
+6 -1
View File
@@ -677,13 +677,18 @@ func restoreInterceptRows(tx *sql.Tx, raw json.RawMessage) error {
source = "unknown"
if row["rule_id"] != nil {
source = "rule"
} else if reason, _ := row["reason"].(string); strings.HasPrefix(reason, "[Model]") {
} else if reason, _ := row["reason"].(string); isModelReason(reason) {
source = "model"
}
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 -1
View File
@@ -61,4 +61,4 @@ require (
modernc.org/memory v1.11.0 // indirect
)
replace github.com/Autumn-27/norma => github.com/BaaRai/Norma v0.4.4
replace github.com/Autumn-27/norma => github.com/BaaRai/Norma v0.4.6
+2 -2
View File
@@ -1,5 +1,5 @@
github.com/BaaRai/Norma v0.4.4 h1:x5UAeaTuhREI1GiIYuwIEybRxr/pLCeCUvXmUbwv5Is=
github.com/BaaRai/Norma v0.4.4/go.mod h1:BudWrf7iHl96FgHqFr/HwN3mKYmGjJDt+xL2ZHXfFZw=
github.com/BaaRai/Norma v0.4.6 h1:sEm58Gx7gw+UU/05aHZBZ+Xqm5dMYiuSQpc1aXnbElc=
github.com/BaaRai/Norma v0.4.6/go.mod h1:BudWrf7iHl96FgHqFr/HwN3mKYmGjJDt+xL2ZHXfFZw=
github.com/andybalholm/brotli v1.2.1 h1:R+f5xP285VArJDRgowrfb9DqL18yVK0gKAW/F+eTWro=
github.com/andybalholm/brotli v1.2.1/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 h1:DklsrG3dyBCFEj5IhUbnKptjxatkF07cF2ak3yi77so=
+18 -9
View File
@@ -476,11 +476,7 @@ func (i *Interceptor) Judge(ctx context.Context, tool string, arguments json.Raw
out.ProfileID = cfg.ProfileID
configJSON, _ := json.Marshal(cfg)
out.ConfigDigest = digestInput(configJSON)
if out.Message == "" {
out.Message = "[model] " + judgeActionLabel(out.Action)
} else if !strings.HasPrefix(out.Message, "[model]") {
out.Message = "[model] " + out.Message
}
out.Message = JudgeReason(out.Action, out.Message)
if out.Action == "ask" {
out.TimeoutEnabled = true
out.TimeoutSeconds = cfg.AskTimeoutSeconds
@@ -489,6 +485,19 @@ func (i *Interceptor) Judge(ctx context.Context, tool string, arguments json.Raw
return out, true
}
// JudgeReason is the reason stored for a judge verdict: the model's message, or
// a label for the action when it gave none, carrying db.ModelReasonPrefix so
// the db readers record decision_source='model'.
func JudgeReason(action, message string) string {
if message == "" {
return db.ModelReasonPrefix + " " + judgeActionLabel(action)
}
if !strings.HasPrefix(message, db.ModelReasonPrefix) {
return db.ModelReasonPrefix + " " + message
}
return message
}
func judgeActionLabel(action string) string {
switch action {
case "allow":
@@ -628,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
}
@@ -649,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
}
@@ -659,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
}
@@ -677,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
}
+4
View File
@@ -312,6 +312,9 @@ type FindingDTO struct {
ReportEvidenceVersion int64 `json:"report_evidence_version"`
ReportStale bool `json:"report_stale"`
TrafficBindings []db.FindingTrafficBinding `json:"traffic_bindings,omitempty"`
PoELevel int `json:"poe_level"`
VerificationStatus string `json:"verification_status"`
VerificationNote string `json:"verification_note"`
ID string `json:"id"`
FindingID string `json:"finding_id,omitempty"` // standalone findings-table id — the handle for status updates
@@ -452,6 +455,7 @@ func findingFromDB(f *db.DBFinding, assets map[int64]*db.Asset) FindingDTO {
FindingID: i64s(f.ID),
TrafficCount: f.TrafficCount, EvidenceVersion: f.EvidenceVersion, ReportEvidenceVersion: f.ReportEvidenceVersion,
ReportStale: f.Report != "" && f.EvidenceVersion != f.ReportEvidenceVersion, TrafficBindings: f.TrafficBindings,
PoELevel: f.PoELevel, VerificationStatus: f.VerificationStatus, VerificationNote: f.VerificationNote,
VulnClass: f.VulnClass,
Name: f.Name,
Severity: f.Severity,
+180
View File
@@ -0,0 +1,180 @@
package server
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"log"
"strings"
"unicode/utf8"
"github.com/Autumn-27/artex/db"
"github.com/Autumn-27/artex/evidence"
)
// S1 receipt check. A finding is verified only when a proof/verification
// traffic body is bound, the agent declared POE L3 or higher, and every quote
// appears verbatim (after whitespace normalization) in one of those response
// bodies. Anything else is unverified, blocked or not_assessed, with a note.
const (
verificationMinPOE = 3
verificationMinQuoteRunes = 8 // shorter normalized quotes are ignored: "x7" proves nothing
verificationReadCap = 4 << 20 // bytes read per bound body; a quote beyond this cannot be confirmed
verificationNoteQuoteLen = 60
)
// boundBody is one bound response body, read through the evidence store and
// normalized for the substring check.
type boundBody struct {
text string // whitespace-normalized text (only when !nonText)
truncated bool // body was longer than verificationReadCap
nonText bool // binary or non-UTF-8 content: never claimed as a match
}
// normalizeEvidenceText trims and collapses every run of whitespace to one space.
// Case is kept.
func normalizeEvidenceText(s string) string {
return strings.Join(strings.Fields(s), " ")
}
// readBoundResponse reads the response side of a bound snapshot through the
// evidence store (same bytes the UI shows), capped at verificationReadCap.
func readBoundResponse(store *evidence.Store, snap db.TrafficEvidenceSnapshot) (boundBody, error) {
f, _, err := store.OpenBody(snap, "response")
if err != nil {
return boundBody{}, err
}
defer f.Close()
raw, err := io.ReadAll(io.LimitReader(f, verificationReadCap+1))
if err != nil {
return boundBody{}, err
}
var bb boundBody
if len(raw) > verificationReadCap {
bb.truncated = true
raw = raw[:verificationReadCap]
// 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]
}
}
if bytes.IndexByte(raw, 0) >= 0 || !utf8.Valid(raw) {
bb.nonText = true
return bb, nil
}
bb.text = normalizeEvidenceText(string(raw))
return bb, nil
}
// assessFindingVerification computes the verification state of a finding from
// its bound traffic and the quotes the agent supplied. poe is the declared POE
// level (0 when absent). It never returns "verified" unless every rule passes.
func (s *Server) assessFindingVerification(ctx context.Context, findingID int64, poe int, quotes []string) (status, note string) {
tr, err := s.m.pg.GetFindingTraffic(ctx, findingID)
if err != nil {
return db.VerificationNotAssessed, "binding read error: " + err.Error()
}
var proofs []db.FindingTrafficBinding
for _, b := range tr.Bindings {
if b.Role == "proof" || b.Role == "verification" {
proofs = append(proofs, b)
}
}
if len(proofs) == 0 {
return db.VerificationUnverified, "no proof traffic bound"
}
if poe < verificationMinPOE {
return db.VerificationUnverified, fmt.Sprintf("POE L%d below L3", poe)
}
if len(quotes) == 0 {
return db.VerificationUnverified, "no evidence quotes provided"
}
var checked []string
for _, q := range quotes {
if utf8.RuneCountInString(normalizeEvidenceText(q)) >= verificationMinQuoteRunes {
checked = append(checked, q)
}
}
if len(checked) == 0 {
return db.VerificationUnverified, "evidence quote too short"
}
store := s.evidenceStore()
bodies := make([]boundBody, 0, len(proofs))
for _, b := range proofs {
bb, err := readBoundResponse(store, b.Snapshot)
if err != nil {
// Fail closed: an unreadable body is never an "unverified" verdict.
return db.VerificationNotAssessed, "evidence body unreadable: " + err.Error()
}
bodies = append(bodies, bb)
}
for _, q := range checked {
nq := normalizeEvidenceText(q)
if nq != "" && quoteInBodies(nq, bodies) {
continue
}
for _, bb := range bodies {
if bb.nonText {
return db.VerificationNotAssessed, "non-text evidence"
}
}
for _, bb := range bodies {
if bb.truncated {
return db.VerificationNotAssessed, "quote not found within read cap of bound evidence"
}
}
return db.VerificationUnverified, "quote not found in bound evidence: " + shortQuote(q)
}
return db.VerificationVerified, ""
}
func quoteInBodies(nq string, bodies []boundBody) bool {
for _, bb := range bodies {
if !bb.nonText && strings.Contains(bb.text, nq) {
return true
}
}
return false
}
// shortQuote returns the first verificationNoteQuoteLen runes of the trimmed
// quote, for use in a note.
func shortQuote(q string) string {
r := []rune(strings.TrimSpace(q))
if len(r) > verificationNoteQuoteLen {
r = r[:verificationNoteQuoteLen]
}
return string(r)
}
// seedFindingVerificationSchema adds the S1 optional properties to the
// update_finding_report schema of existing databases. Idempotent: a property is
// added only when the stored schema lacks it, and user edits are preserved.
func (s *Server) seedFindingVerificationSchema() {
raw, err := json.Marshal(s.toolUpdateFindingReport().InputSchema())
if err != nil {
log.Printf("[verification] tool schema: %v", err)
return
}
var obj map[string]json.RawMessage
if err := json.Unmarshal(raw, &obj); err != nil {
return
}
var properties map[string]json.RawMessage
if err := json.Unmarshal(obj["properties"], &properties); err != nil {
return
}
for _, name := range []string{"poe_level", "evidence_quotes"} {
if len(properties[name]) == 0 {
continue
}
_, err := s.m.pg.Exec(`UPDATE tools SET schema=jsonb_set(schema,ARRAY['properties',$2::text],$3::jsonb,true),updated_at=now()
WHERE key=$1 AND system AND NOT(COALESCE(schema->'properties','{}'::jsonb) ? $2)`, "update_finding_report", name, string(properties[name]))
if err != nil {
log.Printf("[verification] upgrade update_finding_report.%s: %v", name, err)
}
}
}
+178
View File
@@ -0,0 +1,178 @@
package server
import (
"context"
"encoding/json"
"fmt"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"github.com/Autumn-27/artex/db"
actool "github.com/Autumn-27/norma/tool"
)
const verificationBody = "HTTP/1.1 200 OK\nuser admin:\tx7\nend of dump"
func TestNormalizeEvidenceText(t *testing.T) {
cases := map[string]string{
" a\t\tb\r\n c ": "a b c",
"Admin User": "Admin User", // case is kept
"": "",
}
for in, want := range cases {
if got := normalizeEvidenceText(in); got != want {
t.Fatalf("normalize(%q) = %q, want %q", in, got, want)
}
}
}
func TestShortQuoteCapsAtSixtyRunes(t *testing.T) {
long := strings.Repeat("é", 100)
if got := []rune(shortQuote(long)); len(got) != verificationNoteQuoteLen {
t.Fatalf("shortQuote kept %d runes, want %d", len(got), verificationNoteQuoteLen)
}
}
// verificationFixture returns a server with one finding whose traffic "a" is
// bound with the given role and whose response body is verificationBody.
func verificationFixture(t *testing.T, role string) (*Server, *db.RecordedFinding, func(string, string, string) *httptest.ResponseRecorder) {
t.Helper()
s, f, req := trafficEvidenceServer(t)
seedServerEvidenceFlow(t, s, "a", []byte(verificationBody))
if role != "" {
base := fmt.Sprintf("/api/exploration/findings/%d/traffic", f.FindingID)
w := req("POST", base, fmt.Sprintf(`{"traffic_refs":[{"traffic_id":"a","role":%q}]}`, role))
if w.Code != 200 {
t.Fatalf("bind: %d %s", w.Code, w.Body)
}
}
return s, f, req
}
// updateReport calls update_finding_report with the node id, as the agent does.
func updateReport(t *testing.T, s *Server, nodeID int64, extra string) (string, bool) {
t.Helper()
in := fmt.Sprintf(`{"finding_id":%d,"report":"# report","evidence_version":null%s}`, nodeID, extra)
res, err := s.toolUpdateFindingReport().Call(context.Background(), json.RawMessage(in), &actool.ToolContext{})
if err != nil {
t.Fatal(err)
}
return res.Flatten(), res.IsError
}
func storedVerification(t *testing.T, s *Server, findingID int64) (status string, poe int, note, report string) {
t.Helper()
if err := s.m.pg.QueryRow(`SELECT verification_status, poe_level, verification_note, report FROM findings WHERE id=$1`, findingID).
Scan(&status, &poe, &note, &report); err != nil {
t.Fatal(err)
}
return
}
func TestVerificationNoBindingPOE4Unverified(t *testing.T) {
s, f, _ := verificationFixture(t, "")
msg, isErr := updateReport(t, s, f.NodeID, `,"poe_level":4,"evidence_quotes":["x7"]`)
if isErr || !strings.Contains(msg, "verification: unverified - no proof traffic bound") {
t.Fatalf("unexpected result: err=%v %q", isErr, msg)
}
status, poe, note, report := storedVerification(t, s, f.FindingID)
if status != db.VerificationUnverified || poe != 4 || note != "no proof traffic bound" || report == "" {
t.Fatalf("stored: status=%s poe=%d note=%q report=%q", status, poe, note, report)
}
}
func TestVerificationProofPOE3MissingQuoteUnverified(t *testing.T) {
s, f, _ := verificationFixture(t, "proof")
msg, isErr := updateReport(t, s, f.NodeID, `,"poe_level":3,"evidence_quotes":["secret=999"]`)
if isErr || !strings.Contains(msg, "warning:") {
t.Fatalf("unexpected result: err=%v %q", isErr, msg)
}
status, _, note, report := storedVerification(t, s, f.FindingID)
if status != db.VerificationUnverified || !strings.HasPrefix(note, "quote not found in bound evidence: secret=999") || report == "" {
t.Fatalf("stored: status=%s note=%q report=%q", status, note, report)
}
}
func TestVerificationProofPOE3ExactQuoteVerified(t *testing.T) {
s, f, _ := verificationFixture(t, "verification")
// The quote differs from the body only in whitespace runs: it must still match.
msg, isErr := updateReport(t, s, f.NodeID, `,"poe_level":3,"evidence_quotes":["user admin: x7"]`)
if isErr || !strings.Contains(msg, "verification: verified (POE L3)") {
t.Fatalf("unexpected result: err=%v %q", isErr, msg)
}
status, poe, note, _ := storedVerification(t, s, f.FindingID)
if status != db.VerificationVerified || poe != 3 || note != "" {
t.Fatalf("stored: status=%s poe=%d note=%q", status, poe, note)
}
}
func TestVerificationShortQuoteUnverified(t *testing.T) {
s, f, _ := verificationFixture(t, "proof")
// "x7" is present in the body but below verificationMinQuoteRunes: it must not count.
msg, isErr := updateReport(t, s, f.NodeID, `,"poe_level":3,"evidence_quotes":["x7"]`)
if isErr || !strings.Contains(msg, "verification: unverified - evidence quote too short") {
t.Fatalf("unexpected result: err=%v %q", isErr, msg)
}
status, _, note, _ := storedVerification(t, s, f.FindingID)
if status != db.VerificationUnverified || note != "evidence quote too short" {
t.Fatalf("stored: status=%s note=%q", status, note)
}
}
func TestVerificationOldStyleCallUnverifiedWithoutError(t *testing.T) {
s, f, _ := verificationFixture(t, "proof")
msg, isErr := updateReport(t, s, f.NodeID, "")
if isErr || !strings.Contains(msg, "verification: unverified") {
t.Fatalf("unexpected result: err=%v %q", isErr, msg)
}
status, poe, _, report := storedVerification(t, s, f.FindingID)
if status != db.VerificationUnverified || poe != 0 || report == "" {
t.Fatalf("stored: status=%s poe=%d report=%q", status, poe, report)
}
}
func TestVerificationUnreadableStoreNotAssessedReportSaved(t *testing.T) {
s, f, _ := verificationFixture(t, "proof")
// Remove the blobs behind the bound snapshot: the evidence store cannot open the body.
if err := os.RemoveAll(filepath.Join(s.m.dir, "evidence")); err != nil {
t.Fatal(err)
}
msg, isErr := updateReport(t, s, f.NodeID, `,"poe_level":3,"evidence_quotes":["user admin: x7"]`)
if isErr || !strings.Contains(msg, "verification: not_assessed") {
t.Fatalf("unexpected result: err=%v %q", isErr, msg)
}
status, _, note, report := storedVerification(t, s, f.FindingID)
if status != db.VerificationNotAssessed || !strings.HasPrefix(note, "evidence body unreadable") || report == "" {
t.Fatalf("stored: status=%s note=%q report=%q", status, note, report)
}
}
func TestVerificationRejectsOutOfRangePOE(t *testing.T) {
s, f, _ := verificationFixture(t, "proof")
msg, isErr := updateReport(t, s, f.NodeID, `,"poe_level":9`)
if !isErr || !strings.Contains(msg, "invalid poe_level 9") {
t.Fatalf("unexpected result: err=%v %q", isErr, msg)
}
if _, _, _, report := storedVerification(t, s, f.FindingID); report != "" {
t.Fatal("report must not be written when the input is rejected")
}
}
func TestVerificationColumnsExistWithHonestDefaults(t *testing.T) {
s, f, _ := trafficEvidenceServer(t)
var n int
if err := s.m.pg.QueryRow(`SELECT count(*) FROM information_schema.columns
WHERE table_name='findings' AND column_name IN ('poe_level','verification_status','verification_note')`).Scan(&n); err != nil {
t.Fatal(err)
}
if n != 3 {
t.Fatalf("expected 3 verification columns, found %d", n)
}
status, poe, note, _ := storedVerification(t, s, f.FindingID)
if status != db.VerificationUnverified || poe != 0 || note != "" {
t.Fatalf("new row: status=%s poe=%d note=%q", status, poe, note)
}
}
+4 -3
View File
@@ -29,9 +29,10 @@ func findingPaginationParam(raw string, fallback, upperBound int) int {
// query string. The list / grouping / asset tree / export all share one parsing path; new filter options only change here.
func findingFilterFromQuery(q url.Values) db.FindingFilter {
return db.FindingFilter{
Severity: normFilter(q.Get("severity")),
Status: normFilter(q.Get("status")),
VulnClass: normFilter(q.Get("vulnclass")),
Severity: normFilter(q.Get("severity")),
Status: normFilter(q.Get("status")),
VerificationStatus: normFilter(q.Get("verification_status")),
VulnClass: normFilter(q.Get("vulnclass")),
// task_id (independent of the task param, which switches to the per-task-node branch): filters the global table by task.
TaskID: normFilter(q.Get("task_id")),
Query: q.Get("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)
}
}
-1
View File
@@ -11,7 +11,6 @@ import (
)
func TestInterceptFilterHTTP(t *testing.T) {
t.Skip("known failure with a fresh database, pre-existing on unmodified upstream; tracked in BaaRai/ARTEX#9")
dsn, _, err := db.DSN()
if err != nil {
t.Skip("no test database configured")
+36 -2
View File
@@ -2,7 +2,9 @@ package server
import (
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"log"
"strconv"
@@ -430,26 +432,57 @@ func (s *Server) toolUpdateFindingReport() actool.CoreTool {
"finding_id": map[string]any{"type": "integer", "description": "target vulnerability id (the id returned by report_finding)"},
"report": strParam("full report text, Markdown format"),
"evidence_version": map[string]any{"type": "integer", "description": "evidence version returned by get_finding_traffic; prevents the report from overwriting newer evidence changes"},
"poe_level": map[string]any{"type": "integer", "description": "proof-of-exploitation level 0-4: 1 error/timing/response variation, 2 structural manipulation, 3 actual data extracted, 4 critical impact. L3 is the minimum for verified. Omit or 0 when not proven."},
"evidence_quotes": map[string]any{"type": "array", "items": map[string]any{"type": "string"}, "description": "exact lines copied from a bound proof/verification response body (get_finding_traffic). Never from memory; the server checks each one verbatim after whitespace normalization."},
}, "finding_id", "report"),
func(ctx context.Context, in json.RawMessage) (actool.Result, error) {
var a struct {
EvidenceVersion *int64 `json:"evidence_version"`
FindingID json.RawMessage `json:"finding_id"`
Report string `json:"report"`
PoELevel *int `json:"poe_level"`
EvidenceQuotes []string `json:"evidence_quotes"`
}
_ = json.Unmarshal(in, &a)
nodeID := parseProfileID(a.FindingID) // reuses the "number or numeric string" parser
if nodeID <= 0 {
return actool.Errorf("invalid finding_id"), nil
}
poe := 0
if a.PoELevel != nil {
poe = *a.PoELevel
}
if poe < 0 || poe > 4 {
return actool.Errorf(fmt.Sprintf("invalid poe_level %d (want 0-4)", poe)), nil
}
notFound := actool.Errorf(fmt.Sprintf("no vulnerability record found for finding_id=%d (register it with report_finding first)", nodeID))
findingID, err := s.m.pg.FindingIDByNodeID(nodeID)
if errors.Is(err, sql.ErrNoRows) {
return notFound, nil
}
if err != nil {
return actool.Errorf(err.Error()), nil
}
n, err := s.m.pg.SetFindingReportVersionByNodeID(ctx, nodeID, a.Report, a.EvidenceVersion)
if err != nil {
return actool.Errorf(err.Error()), nil
}
if n == 0 {
return actool.Errorf(fmt.Sprintf("no vulnerability record found for finding_id=%d (register it with report_finding first)", nodeID)), nil
return notFound, nil
}
return actool.Text(fmt.Sprintf("finding %d report updated (%d chars)", nodeID, len(a.Report))), nil
// The report is saved above; verification only decides the status. A
// downgrade is recorded and reported, never used to drop the report.
status, note := s.assessFindingVerification(ctx, findingID, poe, a.EvidenceQuotes)
if err := s.m.pg.SetFindingVerification(findingID, status, poe, note); err != nil {
return actool.Errorf("report saved, but verification not recorded: " + err.Error()), nil
}
msg := fmt.Sprintf("finding %d report updated (%d chars)\nverification: %s", nodeID, len(a.Report), status)
if status == db.VerificationVerified {
msg += fmt.Sprintf(" (POE L%d)", poe)
} else {
msg += " - " + note + "\nwarning: the report is saved but the finding is not verified. To reach verified, declare poe_level 3 or 4 and pass exact quotes copied from a bound proof/verification response body."
}
return actool.Text(msg), nil
})
}
@@ -513,6 +546,7 @@ func (s *Server) seedOrchestrationTools() {
s.upgradeReporterTriggerMessage() // backfill migration for old DBs: makes the reporter send evidence_version back (one-time)
s.seedFindingTrafficTools() // adds optional evidence params and read-only evidence tools, preserving user config
s.seedFindingWorkflowTools()
s.seedFindingVerificationSchema() // S1: poe_level / evidence_quotes on update_finding_report, added once per DB
// Note: pentest's default tool bindings need no migration — BuiltinToolSeeds already seeds
// list_assets/insert_assets/report_finding/list_findings/list_companies together with
// pentest at fresh initialization (no old DBs exist in this project, so no migration).
@@ -36,8 +36,8 @@ import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Spinner } from "@/components/ui/spinner";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { statusMeta } from "@/lib/status";
import type { ActiveFindingRetest, Finding, FindingStatus, Severity } from "@/lib/types";
import { statusMeta, type Tone, toneClasses } from "@/lib/status";
import type { ActiveFindingRetest, Finding, FindingStatus, Severity, VerificationStatus } from "@/lib/types";
import { cn } from "@/lib/utils";
export const SEVERITIES: Severity[] = ["critical", "high", "medium", "low"];
@@ -54,6 +54,45 @@ export const FINDING_STATUSES: FindingStatus[] = [
"risk_accepted",
];
export const VERIFICATION_STATUSES: VerificationStatus[] = [
"verified",
"unverified",
"blocked",
"not_assessed",
"false_positive",
];
// Verification outcome → label/tone, on the same tone tokens StatusBadge uses.
export const VERIFICATION_META: Record<VerificationStatus, { label: string; tone: Tone }> = {
verified: { label: "Verified", tone: "green" },
unverified: { label: "Unverified", tone: "slate" },
blocked: { label: "Blocked", tone: "amber" },
not_assessed: { label: "Not assessed", tone: "red" },
false_positive: { label: "False positive", tone: "neutral" },
};
// VerificationBadge shows the automated verification outcome ("Verified L3" when a
// proof-of-exploitation level is declared). It renders nothing when the finding carries
// no verification status (in-task nodes, payloads from before the column existed).
export function VerificationBadge({ finding }: { finding: Pick<Finding, "verification_status" | "poe_level"> }) {
const meta = finding.verification_status ? VERIFICATION_META[finding.verification_status] : undefined;
if (!meta) return null;
const label =
finding.verification_status === "verified" && finding.poe_level
? `${meta.label} L${finding.poe_level}`
: meta.label;
return (
<span
className={cn(
"inline-flex w-fit items-center whitespace-nowrap rounded-md border px-2 py-0.5 font-medium text-xs",
toneClasses[meta.tone],
)}
>
{label}
</span>
);
}
export const UNASSIGNED_TASK = "__unassigned__";
// Inline edit buffer: the expanded row's name/category/severity.
@@ -213,7 +252,10 @@ export function FindingsTable({
<span className="truncate font-medium">{f.name || f.vulnclass || "Uncategorized"}</span>
)}
<span className="truncate text-xs text-muted-foreground">{f.summary}</span>
<Badge variant="outline">Traffic evidence: {f.traffic_count ?? 0}</Badge>
<div className="flex flex-wrap items-center gap-1">
<Badge variant="outline">Traffic evidence: {f.traffic_count ?? 0}</Badge>
<VerificationBadge finding={f} />
</div>
</div>
</TableCell>
<TableCell>
@@ -25,6 +25,7 @@ import { api } from "@/lib/api";
import { statusMeta } from "@/lib/status";
import type { Finding, FindingStatus, Severity } from "@/lib/types";
import { VerificationBadge } from "../_components/findings-table";
import { FindingLineageView } from "./lineage";
const SEVERITIES: Severity[] = ["critical", "high", "medium", "low"];
@@ -147,6 +148,7 @@ function FindingDetailInner() {
<Separator orientation="vertical" className="mx-1 h-4" />
<StatusBadge domain="severity" value={finding.severity} dot />
<StatusBadge domain="finding" value={finding.status} dot />
<VerificationBadge finding={finding} />
{finding.inherited && finding.source_task_id && (
<Badge variant="outline">Source task #{finding.source_task_id} · read-only</Badge>
)}
@@ -272,6 +274,18 @@ function FindingDetailInner() {
)}
</FieldRow>
{/* Verification: automated outcome; the note explains any state other than verified */}
{finding.verification_status && (
<FieldRow label="Verification">
<VerificationBadge finding={finding} />
{finding.verification_status !== "verified" && finding.verification_note?.trim() && (
<p className="whitespace-pre-wrap break-words text-muted-foreground text-xs">
{finding.verification_note}
</p>
)}
</FieldRow>
)}
{/* Vulnerability type */}
<FieldRow label="Vulnerability type">
{finding.vulnclass ? (
+52 -9
View File
@@ -49,6 +49,7 @@ import type {
FindingStats,
FindingStatus,
Severity,
VerificationStatus,
} from "@/lib/types";
import { cn } from "@/lib/utils";
@@ -63,6 +64,8 @@ import {
isSameFinding,
SEVERITIES,
UNASSIGNED_TASK,
VERIFICATION_META,
VERIFICATION_STATUSES,
} from "./_components/findings-table";
const FINDING_LIST_PREFERENCE_KEY = "artex_finding_list_preferences";
@@ -132,6 +135,7 @@ export default function FindingsPage() {
const [view, setView] = React.useState<FindingView>("flat");
const [severity, setSeverity] = React.useState<"all" | Severity>("all");
const [status, setStatus] = React.useState<"all" | FindingStatus>("all");
const [verificationStatus, setVerificationStatus] = React.useState<"all" | VerificationStatus>("all");
const [vulnclass, setVulnclass] = React.useState<string>("all");
const [task, setTask] = React.useState<string>("all");
const [sort, setSort] = React.useState<"severity" | "time">("severity");
@@ -162,7 +166,7 @@ export default function FindingsPage() {
const retestRefreshVersion = React.useRef(0);
const [deepenDescription, setDeepenDescription] = React.useState("");
const [deepening, setDeepening] = React.useState(false);
const filterFingerprint = JSON.stringify([severity, status, vulnclass, task, sort, query]);
const filterFingerprint = JSON.stringify([severity, status, verificationStatus, vulnclass, task, sort, query]);
const activeFilterFingerprint = React.useRef(filterFingerprint);
activeFilterFingerprint.current = filterFingerprint;
@@ -200,6 +204,7 @@ export default function FindingsPage() {
view?: unknown;
severity?: unknown;
status?: unknown;
verificationStatus?: unknown;
vulnclass?: unknown;
task?: unknown;
sort?: unknown;
@@ -211,6 +216,12 @@ export default function FindingsPage() {
if (parsed.status === "all" || FINDING_STATUSES.includes(parsed.status as FindingStatus)) {
setStatus(parsed.status as "all" | FindingStatus);
}
if (
parsed.verificationStatus === "all" ||
VERIFICATION_STATUSES.includes(parsed.verificationStatus as VerificationStatus)
) {
setVerificationStatus(parsed.verificationStatus as "all" | VerificationStatus);
}
if (typeof parsed.vulnclass === "string" && parsed.vulnclass) setVulnclass(parsed.vulnclass);
if (typeof parsed.task === "string" && parsed.task) setTask(parsed.task);
if (parsed.sort === "severity" || parsed.sort === "time") setSort(parsed.sort);
@@ -225,9 +236,9 @@ export default function FindingsPage() {
if (!preferencesHydrated) return;
setLocalStorageValue(
FINDING_LIST_PREFERENCE_KEY,
JSON.stringify({ view, severity, status, vulnclass, task, sort }),
JSON.stringify({ view, severity, status, verificationStatus, vulnclass, task, sort }),
);
}, [preferencesHydrated, severity, sort, status, task, view, vulnclass]);
}, [preferencesHydrated, severity, sort, status, task, verificationStatus, view, vulnclass]);
React.useEffect(() => {
const timer = window.setTimeout(() => setQuery(search.trim()), 300);
@@ -286,7 +297,7 @@ export default function FindingsPage() {
await api.exportFindings({
format: exportFormat,
scope: exportScope,
filters: { severity, status, vulnclass, task, query, sort },
filters: { severity, status, verificationStatus, vulnclass, task, query, sort },
ids: [...selectedIds],
});
setExportOpen(false);
@@ -326,6 +337,7 @@ export default function FindingsPage() {
pageSize: flatPageSize,
severity,
status,
verificationStatus,
vulnclass,
task,
query,
@@ -339,7 +351,19 @@ export default function FindingsPage() {
// Polling keeps the last successful snapshot visible.
setFlat((current) => ({ ...current, loading: false }));
}
}, [activeAssetScope, filterFingerprint, flatPage, flatPageSize, severity, status, vulnclass, task, query, sort]);
}, [
activeAssetScope,
filterFingerprint,
flatPage,
flatPageSize,
severity,
status,
verificationStatus,
vulnclass,
task,
query,
sort,
]);
// loadAssetTree fetches the whole asset tree. The tree doesn't change with the selected node (otherwise
// picking one would collapse it into a single chain), so no assetScope here.
@@ -349,7 +373,7 @@ export default function FindingsPage() {
const request = ++assetTreeRequest.current;
setAssetTree((current) => ({ ...current, loading: true }));
try {
const result = await api.findingAssetTree({ severity, status, vulnclass, task, query, sort });
const result = await api.findingAssetTree({ severity, status, verificationStatus, vulnclass, task, query, sort });
if (request !== assetTreeRequest.current || activeFilterFingerprint.current !== requestFilter) return;
setAssetTree({
nodes: result.nodes ?? [],
@@ -364,7 +388,7 @@ export default function FindingsPage() {
setAssetTree((current) => ({ ...current, loading: false }));
toast.error(`Failed to load the asset tree: ${(e as Error).message}`);
}
}, [filterFingerprint, severity, status, vulnclass, task, query, sort]);
}, [filterFingerprint, severity, status, verificationStatus, vulnclass, task, query, sort]);
const refreshGroups = React.useCallback(async () => {
const requestFilter = filterFingerprint;
@@ -376,6 +400,7 @@ export default function FindingsPage() {
pageSize,
severity,
status,
verificationStatus,
vulnclass,
task,
query,
@@ -388,7 +413,7 @@ export default function FindingsPage() {
} catch {
// Polling keeps the last successful snapshot visible.
}
}, [filterFingerprint, page, pageSize, severity, status, vulnclass, task, query, sort]);
}, [filterFingerprint, page, pageSize, severity, status, verificationStatus, vulnclass, task, query, sort]);
const loadGroup = React.useCallback(
async (key: string, groupPage: number, groupPageSize: number) => {
@@ -413,6 +438,7 @@ export default function FindingsPage() {
pageSize: groupPageSize,
severity,
status,
verificationStatus,
vulnclass,
task: key,
query,
@@ -447,7 +473,7 @@ export default function FindingsPage() {
}));
}
},
[filterFingerprint, severity, status, vulnclass, query, sort],
[filterFingerprint, severity, status, verificationStatus, vulnclass, query, sort],
);
React.useEffect(() => {
@@ -898,6 +924,23 @@ export default function FindingsPage() {
</SelectContent>
</Select>
<Select
value={verificationStatus}
onValueChange={(v) => setVerificationStatus(v as "all" | VerificationStatus)}
>
<SelectTrigger size="sm" className="w-48">
<SelectValue placeholder="Verification" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All verification states</SelectItem>
{VERIFICATION_STATUSES.map((st) => (
<SelectItem key={st} value={st}>
{VERIFICATION_META[st].label}
</SelectItem>
))}
</SelectContent>
</Select>
<Select value={vulnclass} onValueChange={setVulnclass}>
<SelectTrigger size="sm" className="w-40">
<SelectValue placeholder="Vulnerability type" />
+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>
+2
View File
@@ -218,6 +218,7 @@ function findingFilterParams(q: Omit<FindingQuery, "page" | "pageSize">): URLSea
const p = new URLSearchParams();
if (q.severity && q.severity !== "all") p.set("severity", q.severity);
if (q.status && q.status !== "all") p.set("status", q.status);
if (q.verificationStatus && q.verificationStatus !== "all") p.set("verification_status", q.verificationStatus);
if (q.vulnclass && q.vulnclass !== "all") p.set("vulnclass", q.vulnclass);
if (q.task && q.task !== "all") p.set("task_id", q.task);
if (q.query?.trim()) p.set("q", q.query.trim());
@@ -230,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));
+12
View File
@@ -481,6 +481,10 @@ export type FindingStatus =
| "duplicate"
| "risk_accepted";
// Verification status (S1): the automated proof-of-exploitation outcome, distinct from the
// human triage status above. "blocked" is produced by no path yet but is displayed when present.
export type VerificationStatus = "verified" | "unverified" | "blocked" | "not_assessed" | "false_positive";
// FindingAsset is an asset bound to a vulnerability (label pre-rendered by the backend).
export interface FindingAsset {
id: string;
@@ -493,6 +497,9 @@ export interface Finding {
evidence_version?: number;
report_evidence_version?: number;
report_stale?: boolean;
poe_level?: number; // proof-of-exploitation level (3 or 4 when verified); 0 = none declared
verification_status?: VerificationStatus; // automated verification outcome (S1)
verification_note?: string; // why the status was reached; empty when verified
id: string;
finding_id?: string; // row id in the standalone findings table, the handle for status updates (may be missing for old in-task nodes)
vulnclass: string;
@@ -575,6 +582,7 @@ export interface FindingQuery {
pageSize: number;
severity?: "all" | Severity;
status?: "all" | FindingStatus;
verificationStatus?: "all" | VerificationStatus;
vulnclass?: string;
task?: string; // task id; "all"/empty = no task filter
query?: string;
@@ -1339,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;
@@ -1383,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.