mirror of
https://github.com/BaaRai/ARTEX.git
synced 2026-11-13 11:40:53 +08:00
Normalise and length-check ledger fields in db.RecordCoverage, frame the planner gap lines in the data envelope, cap the wrap-up (25) and report (200) gap lists, cap current rows at 2000 per task. Replace the activity budget marker with tasks.end_reason, written with the guarded terminal status and cleared on revive. Generic 500 message in taskCoverage; early return on an empty ledger in the planner block.
336 lines
13 KiB
Go
336 lines
13 KiB
Go
package db
|
||
|
||
import (
|
||
"fmt"
|
||
"sort"
|
||
"strings"
|
||
"time"
|
||
"unicode"
|
||
"unicode/utf8"
|
||
)
|
||
|
||
// Coverage ledger (modeled on Strix strix/report/coverage.py, skill_coverage_gaps).
|
||
// Two layers: agent_reported = the entries workers write with record_coverage;
|
||
// machine_observed = execution facts (asset coverage from TaskCoverageWithSources,
|
||
// findings). The ledger adds the risk-class dimension and the negative results; it
|
||
// never recomputes tested assets. A risk class with no entry is unexamined, never
|
||
// clean, and a no_issue_found is never derived from the absence of a finding.
|
||
|
||
// Ledger outcomes.
|
||
const (
|
||
OutcomeReported = "reported"
|
||
OutcomeNoIssueFound = "no_issue_found"
|
||
OutcomeRuledOut = "ruled_out"
|
||
OutcomeNotApplicable = "not_applicable"
|
||
OutcomeNeedsFollowUp = "needs_follow_up"
|
||
)
|
||
|
||
// Gap kinds.
|
||
const (
|
||
GapNeedsFollowUp = "needs_follow_up"
|
||
GapUnrecordedRiskClass = "unrecorded_risk_class"
|
||
GapMissingEvidence = "missing_evidence"
|
||
GapMissingReason = "missing_reason"
|
||
)
|
||
|
||
// EndReasonBudget is the tasks.end_reason of a run settled by the run-budget
|
||
// wrap-up (server/engine_timeout.go); completeness reads it back. The column is
|
||
// per run: a revive to running clears it (ApplyTaskAdmission, SetStatus).
|
||
const EndReasonBudget = "budget"
|
||
|
||
// Ledger field limits (characters, after normalisation). Over a limit the write
|
||
// is refused, never truncated.
|
||
const (
|
||
maxCoverageSurface = 200
|
||
maxCoverageRiskArea = 64
|
||
maxCoverageEvidence = 500
|
||
maxCoverageReason = 500
|
||
// MaxCoverageRowsPerTask caps the current rows of one task: a new key beyond
|
||
// it is refused, an update of an existing key is always allowed. The history
|
||
// is append-only and not capped.
|
||
MaxCoverageRowsPerTask = 2000
|
||
)
|
||
|
||
// ValidCoverageOutcome reports whether o is one of the five ledger outcomes.
|
||
func ValidCoverageOutcome(o string) bool {
|
||
switch o {
|
||
case OutcomeReported, OutcomeNoIssueFound, OutcomeRuledOut, OutcomeNotApplicable, OutcomeNeedsFollowUp:
|
||
return true
|
||
}
|
||
return false
|
||
}
|
||
|
||
// CoverageEntry is one agent_reported row: surface + risk_area + outcome + evidence + reason.
|
||
type CoverageEntry struct {
|
||
Surface string `json:"surface"`
|
||
RiskArea string `json:"risk_area"`
|
||
Outcome string `json:"outcome"`
|
||
Evidence string `json:"evidence"`
|
||
Reason string `json:"reason"`
|
||
Worker string `json:"worker,omitempty"`
|
||
IntentID int64 `json:"intent_id,omitempty"`
|
||
UpdatedAt time.Time `json:"updated_at"`
|
||
}
|
||
|
||
// CoverageGap is one open item: a follow-up, an unrecorded mandatory class, or an
|
||
// entry missing its required evidence/reason.
|
||
type CoverageGap struct {
|
||
Kind string `json:"kind"`
|
||
Surface string `json:"surface,omitempty"`
|
||
RiskArea string `json:"risk_area"`
|
||
}
|
||
|
||
func (g CoverageGap) String() string {
|
||
if g.Surface == "" {
|
||
return g.Kind + " " + g.RiskArea
|
||
}
|
||
return g.Kind + " " + g.Surface + " " + g.RiskArea
|
||
}
|
||
|
||
// CoverageCompleteness says whether the run ended as a complete scan. Caveat is
|
||
// always set: an incomplete run is never reported silently.
|
||
type CoverageCompleteness struct {
|
||
Complete bool `json:"complete"`
|
||
State string `json:"state"` // complete | in_progress | paused | timeout | budget | aborted
|
||
Caveat string `json:"caveat"`
|
||
}
|
||
|
||
// CoverageLedger is the ledger document of one task.
|
||
type CoverageLedger struct {
|
||
Entries []CoverageEntry `json:"entries"`
|
||
Gaps []CoverageGap `json:"gaps"`
|
||
Unexamined []string `json:"unexamined"` // mandatory classes with no entry
|
||
FindingsByClass map[string]int `json:"findings_by_class"` // machine_observed
|
||
Completeness *CoverageCompleteness `json:"completeness"`
|
||
}
|
||
|
||
func normRiskArea(s string) string { return strings.ToLower(NormalizeCoverageField(s)) }
|
||
|
||
// NormalizeCoverageField folds an agent-reported ledger field to one line: every
|
||
// control character (C0, DEL, C1) and the Unicode line/paragraph separators become
|
||
// a space, runs of whitespace collapse to one space, and the ends are trimmed. The
|
||
// fields reach the planner prompt and the report, so they must not carry newlines.
|
||
func NormalizeCoverageField(s string) string {
|
||
s = strings.Map(func(r rune) rune {
|
||
if unicode.IsControl(r) || r == '
' || r == '
' {
|
||
return ' '
|
||
}
|
||
return r
|
||
}, s)
|
||
return strings.Join(strings.Fields(s), " ")
|
||
}
|
||
|
||
// normalizeCoverageEntry normalises the agent-reported fields of e and checks
|
||
// them: required fields, the outcome and the length limits.
|
||
func normalizeCoverageEntry(e CoverageEntry) (CoverageEntry, error) {
|
||
e.Surface, e.RiskArea = NormalizeCoverageField(e.Surface), normRiskArea(e.RiskArea)
|
||
e.Evidence, e.Reason = NormalizeCoverageField(e.Evidence), NormalizeCoverageField(e.Reason)
|
||
if e.Surface == "" || e.RiskArea == "" {
|
||
return e, fmt.Errorf("task, surface and risk_area are required")
|
||
}
|
||
if !ValidCoverageOutcome(e.Outcome) {
|
||
return e, fmt.Errorf("outcome must be one of reported, no_issue_found, ruled_out, not_applicable, needs_follow_up")
|
||
}
|
||
for _, f := range []struct {
|
||
name, v string
|
||
max int
|
||
}{{"surface", e.Surface, maxCoverageSurface}, {"risk_area", e.RiskArea, maxCoverageRiskArea},
|
||
{"evidence", e.Evidence, maxCoverageEvidence}, {"reason", e.Reason, maxCoverageReason}} {
|
||
if utf8.RuneCountInString(f.v) > f.max {
|
||
return e, fmt.Errorf("%s too long (max %d)", f.name, f.max)
|
||
}
|
||
}
|
||
return e, nil
|
||
}
|
||
|
||
// RecordCoverage upserts the current row of (task, surface, risk_area) and appends
|
||
// the write to the history, in one transaction. The fields are normalised and
|
||
// checked here (normalizeCoverageEntry), whatever the caller.
|
||
func (s *AssetStore) RecordCoverage(taskID int64, e CoverageEntry) error {
|
||
if taskID <= 0 {
|
||
return fmt.Errorf("task, surface and risk_area are required")
|
||
}
|
||
e, err := normalizeCoverageEntry(e)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
tx, err := s.db.Begin()
|
||
if err != nil {
|
||
return err
|
||
}
|
||
defer tx.Rollback() //nolint:errcheck
|
||
// Serialise the writers of one task so the row cap holds under concurrency.
|
||
if _, err := tx.Exec(`SELECT 1 FROM tasks WHERE id=$1 FOR UPDATE`, taskID); err != nil {
|
||
return err
|
||
}
|
||
var exists bool
|
||
var current int
|
||
if err := tx.QueryRow(`SELECT EXISTS (SELECT 1 FROM coverage_ledger WHERE task_id=$1 AND surface=$2 AND risk_area=$3),
|
||
(SELECT COUNT(*) FROM coverage_ledger WHERE task_id=$1)`, taskID, e.Surface, e.RiskArea).Scan(&exists, ¤t); err != nil {
|
||
return err
|
||
}
|
||
if !exists && current >= MaxCoverageRowsPerTask {
|
||
return fmt.Errorf("coverage ledger full for this task (max %d entries): update an existing surface and risk_area instead", MaxCoverageRowsPerTask)
|
||
}
|
||
args := []any{taskID, e.Surface, e.RiskArea, e.Outcome, e.Evidence, e.Reason, e.Worker, nullID(e.IntentID)}
|
||
if _, err := tx.Exec(`INSERT INTO coverage_ledger_history(task_id, surface, risk_area, outcome, evidence, reason, worker, intent_id)
|
||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8)`, args...); err != nil {
|
||
return err
|
||
}
|
||
if _, err := tx.Exec(`INSERT INTO coverage_ledger(task_id, surface, risk_area, outcome, evidence, reason, worker, intent_id)
|
||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8)
|
||
ON CONFLICT (task_id, surface, risk_area) DO UPDATE SET outcome=EXCLUDED.outcome, evidence=EXCLUDED.evidence,
|
||
reason=EXCLUDED.reason, worker=EXCLUDED.worker, intent_id=EXCLUDED.intent_id, updated_at=now()`, args...); err != nil {
|
||
return err
|
||
}
|
||
return tx.Commit()
|
||
}
|
||
|
||
func nullID(id int64) any {
|
||
if id <= 0 {
|
||
return nil
|
||
}
|
||
return id
|
||
}
|
||
|
||
// CoverageEntryCount returns the number of current ledger rows of a task.
|
||
func (s *AssetStore) CoverageEntryCount(taskID int64) (int, error) {
|
||
var n int
|
||
err := s.db.QueryRow(`SELECT COUNT(*) FROM coverage_ledger WHERE task_id=$1`, taskID).Scan(&n)
|
||
return n, err
|
||
}
|
||
|
||
// CoverageLedger loads the ledger document of a task: its entries, the mandatory
|
||
// classes (goal vulnclass), the findings per class and the task's end state.
|
||
func (s *AssetStore) CoverageLedger(taskID int64) (*CoverageLedger, error) {
|
||
var entries []CoverageEntry
|
||
rows, err := s.db.Query(`SELECT surface, risk_area, outcome, evidence, reason, worker, COALESCE(intent_id,0), updated_at
|
||
FROM coverage_ledger WHERE task_id=$1 ORDER BY risk_area, surface`, taskID)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
defer rows.Close()
|
||
for rows.Next() {
|
||
var e CoverageEntry
|
||
if err := rows.Scan(&e.Surface, &e.RiskArea, &e.Outcome, &e.Evidence, &e.Reason, &e.Worker, &e.IntentID, &e.UpdatedAt); err != nil {
|
||
return nil, err
|
||
}
|
||
entries = append(entries, e)
|
||
}
|
||
if err := rows.Err(); err != nil {
|
||
return nil, err
|
||
}
|
||
var classes []string
|
||
if err := s.scanStrings(&classes, `SELECT DISTINCT lower(btrim(n.payload->>'vulnclass')) FROM exploration_nodes n
|
||
JOIN tasks t ON t.exploration_id=n.exploration_id
|
||
WHERE t.id=$1 AND n.kind='goal' AND COALESCE(btrim(n.payload->>'vulnclass'),'')<>'' ORDER BY 1`, taskID); err != nil {
|
||
return nil, err
|
||
}
|
||
byClass := map[string]int{}
|
||
frows, err := s.db.Query(`SELECT lower(btrim(vulnclass)), COUNT(*) FROM findings WHERE task_id=$1 AND btrim(vulnclass)<>'' GROUP BY 1`, taskID)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
defer frows.Close()
|
||
for frows.Next() {
|
||
var c string
|
||
var n int
|
||
if err := frows.Scan(&c, &n); err != nil {
|
||
return nil, err
|
||
}
|
||
byClass[c] = n
|
||
}
|
||
if err := frows.Err(); err != nil {
|
||
return nil, err
|
||
}
|
||
var status string
|
||
var paused, budget, pastDeadline bool
|
||
// end_reason is per run (cleared when a revive moves the task back to running),
|
||
// so a budget wrap-up of an earlier run never marks a later one.
|
||
if err := s.db.QueryRow(`SELECT t.status, COALESCE(t.paused,false), t.end_reason=$2,
|
||
COALESCE(t.deadline_at IS NOT NULL AND t.completed_at IS NOT NULL AND t.completed_at >= t.deadline_at, false)
|
||
FROM tasks t WHERE t.id=$1`, taskID, EndReasonBudget).Scan(&status, &paused, &budget, &pastDeadline); err != nil {
|
||
return nil, err
|
||
}
|
||
l := BuildCoverageLedger(entries, classes, CoverageCompletenessFor(status, paused, budget, pastDeadline))
|
||
l.FindingsByClass = byClass
|
||
return l, nil
|
||
}
|
||
|
||
func (s *AssetStore) scanStrings(out *[]string, q string, args ...any) error {
|
||
rows, err := s.db.Query(q, args...)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
defer rows.Close()
|
||
for rows.Next() {
|
||
var v string
|
||
if err := rows.Scan(&v); err != nil {
|
||
return err
|
||
}
|
||
*out = append(*out, v)
|
||
}
|
||
return rows.Err()
|
||
}
|
||
|
||
// BuildCoverageLedger derives the gaps from the entries and the mandatory classes.
|
||
// Gap sources: (a) needs_follow_up entries; (b) a mandatory class with no entry at
|
||
// all (unrecorded_risk_class, also listed as unexamined); (c) an entry missing its
|
||
// required evidence (no_issue_found) or reason (ruled_out, not_applicable). Nothing
|
||
// here reads findings: an absent finding never closes a class.
|
||
func BuildCoverageLedger(entries []CoverageEntry, mandatory []string, c CoverageCompleteness) *CoverageLedger {
|
||
l := &CoverageLedger{Entries: entries, Gaps: []CoverageGap{}, Unexamined: []string{}, FindingsByClass: map[string]int{}, Completeness: &c}
|
||
if l.Entries == nil {
|
||
l.Entries = []CoverageEntry{}
|
||
}
|
||
seen := map[string]bool{}
|
||
for _, e := range entries {
|
||
seen[normRiskArea(e.RiskArea)] = true
|
||
switch {
|
||
case e.Outcome == OutcomeNeedsFollowUp:
|
||
l.Gaps = append(l.Gaps, CoverageGap{GapNeedsFollowUp, e.Surface, e.RiskArea})
|
||
case e.Outcome == OutcomeNoIssueFound && strings.TrimSpace(e.Evidence) == "":
|
||
l.Gaps = append(l.Gaps, CoverageGap{GapMissingEvidence, e.Surface, e.RiskArea})
|
||
case (e.Outcome == OutcomeRuledOut || e.Outcome == OutcomeNotApplicable) && strings.TrimSpace(e.Reason) == "":
|
||
l.Gaps = append(l.Gaps, CoverageGap{GapMissingReason, e.Surface, e.RiskArea})
|
||
}
|
||
}
|
||
classes := map[string]bool{}
|
||
for _, m := range mandatory {
|
||
if m = normRiskArea(m); m != "" && !seen[m] && !classes[m] {
|
||
classes[m] = true
|
||
l.Unexamined = append(l.Unexamined, m)
|
||
}
|
||
}
|
||
sort.Strings(l.Unexamined)
|
||
unrecorded := make([]CoverageGap, 0, len(l.Unexamined))
|
||
for _, m := range l.Unexamined {
|
||
unrecorded = append(unrecorded, CoverageGap{Kind: GapUnrecordedRiskClass, RiskArea: m})
|
||
}
|
||
l.Gaps = append(unrecorded, l.Gaps...) // the central invariant first
|
||
return l
|
||
}
|
||
|
||
// CoverageCompletenessFor maps a task's end state to completeness. Only a task that
|
||
// reached done on its own is complete; timeout, budget wrap-up (even when the final
|
||
// round met the goals), pause, abort (failed) and a still-running task are not.
|
||
func CoverageCompletenessFor(status string, paused, budget, pastDeadline bool) CoverageCompleteness {
|
||
state := "complete"
|
||
switch {
|
||
case budget:
|
||
state = "budget"
|
||
case status == "timeout" || (status == "done" && pastDeadline):
|
||
state = "timeout"
|
||
case status == "failed":
|
||
state = "aborted"
|
||
case paused || status == "paused":
|
||
state = "paused"
|
||
case status != "done":
|
||
state = "in_progress"
|
||
}
|
||
if state == "complete" {
|
||
return CoverageCompleteness{true, state, "Run ended normally. Classes listed as unexamined were not tested and are not clean."}
|
||
}
|
||
return CoverageCompleteness{false, state, "Incomplete scan (" + state + "): the absence of a finding does not mean the target is clean; unexamined classes and open gaps were not covered."}
|
||
}
|