Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions packages/core/client/localstore.go
Original file line number Diff line number Diff line change
Expand Up @@ -435,6 +435,21 @@ func readLog() []logLine {
return out
}

// AuditEntryFromLocalLine decodes one raw line of the local audit log into the
// same entry shape List returns. Callers that rewrite the log file themselves
// use it with AuditQuery.Matches so deleting "everything in the current view"
// applies exactly the filter the view used, instead of a second copy of it.
func AuditEntryFromLocalLine(raw []byte) (AuditEntry, bool) {
var l logLine
if json.Unmarshal(raw, &l) != nil {
return AuditEntry{}, false
}
if t, err := time.Parse(time.RFC3339Nano, l.TS); err == nil {
l.at = t.UnixMilli()
}
return toAuditEntry(l), true
}

func toAuditEntry(l logLine) AuditEntry {
decision := l.Decision
if decision == "" {
Expand Down
127 changes: 74 additions & 53 deletions packages/core/client/resources_audit.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,16 @@ import (
"errors"
"fmt"
"strings"
"time"
)

type Audit struct{ c *Client }

// MaxAuditPage is the largest page Audit.List returns; a larger Limit is clamped
// to it. Callers that need every row (export, bulk delete) must walk the pages
// instead of asking for one oversized page and assuming they got everything.
const MaxAuditPage = 1000

type AuditQuery struct {
Filter string
Tool string
Expand All @@ -26,75 +32,90 @@ type AuditQuery struct {
Signal string
}

// Matches reports whether one entry falls inside the query's filter set. Limit,
// Offset and the paging window are deliberately ignored: they scope the page a
// caller shows, not the set a filter selects, so callers that act on "everything
// currently shown" (delete, export) can reuse this predicate on the raw log
// instead of re-implementing the filter and drifting away from the view.
func (q AuditQuery) Matches(e AuditEntry) bool {
decision := q.Filter
if decision == "DENIED" {
decision = "DENY"
}
lower := strings.ToLower
if decision != "" && e.Decision != decision {
return false
}
if q.Tool != "" && !strings.Contains(lower(e.ToolName), lower(q.Tool)) {
return false
}
if q.AgentName != "" {
name := ""
if e.AgentName != nil {
name = *e.AgentName
}
if !strings.Contains(lower(name), lower(q.AgentName)) {
return false
}
}
if q.SessionID != "" && (e.SessionID == nil || *e.SessionID != q.SessionID) {
return false
}
reason := ""
if e.Reason != nil {
reason = *e.Reason
}
if q.Signal == "dlp" && len(e.DLPMatches) == 0 && !strings.Contains(lower(reason), "dlp") {
return false
}
if q.Signal == "ratelimit" && !e.RateLimitBurst && !strings.Contains(lower(reason), "rate limit") {
return false
}
if q.Search != "" {
needle := lower(q.Search)
if !strings.Contains(lower(e.ToolName), needle) &&
!strings.Contains(lower(reason), needle) &&
!strings.Contains(lower(string(e.ArgumentsSummary)), needle) {
return false
}
}
if q.From > 0 || q.To > 0 {
at := int64(0)
if t, err := time.Parse(time.RFC3339Nano, e.CreatedAt); err == nil {
at = t.UnixMilli()
}
if at <= 0 {
return false
}
if q.From > 0 && at < q.From {
return false
}
if q.To > 0 && at > q.To {
return false
}
}
return true
}

func (a Audit) List(ctx context.Context, q AuditQuery) (AuditList, error) {
limit := q.Limit
if limit < 1 {
limit = 50
}
if limit > 1000 {
limit = 1000
if limit > MaxAuditPage {
limit = MaxAuditPage
}
offset := q.Offset
if offset < 0 {
offset = 0
}

decision := q.Filter
if decision == "DENIED" {
decision = "DENY"
}
lower := strings.ToLower
rows := []AuditEntry{}
for _, l := range readLog() {
e := toAuditEntry(l)
if decision != "" && e.Decision != decision {
continue
}
if q.Tool != "" && !strings.Contains(lower(e.ToolName), lower(q.Tool)) {
continue
}
if q.AgentName != "" {
name := ""
if e.AgentName != nil {
name = *e.AgentName
}
if !strings.Contains(lower(name), lower(q.AgentName)) {
continue
}
}
if q.SessionID != "" && (e.SessionID == nil || *e.SessionID != q.SessionID) {
if !q.Matches(e) {
continue
}
reason := ""
if e.Reason != nil {
reason = *e.Reason
}
if q.Signal == "dlp" && len(e.DLPMatches) == 0 && !strings.Contains(lower(reason), "dlp") {
continue
}
if q.Signal == "ratelimit" && !e.RateLimitBurst && !strings.Contains(lower(reason), "rate limit") {
continue
}
if q.Search != "" {
needle := lower(q.Search)
if !strings.Contains(lower(e.ToolName), needle) &&
!strings.Contains(lower(reason), needle) &&
!strings.Contains(lower(string(e.ArgumentsSummary)), needle) {
continue
}
}
if q.From > 0 || q.To > 0 {
at := l.at
if at <= 0 {
continue
}
if q.From > 0 && at < q.From {
continue
}
if q.To > 0 && at > q.To {
continue
}
}
rows = append(rows, e)
}

Expand Down
12 changes: 11 additions & 1 deletion packages/core/client/resources_audit_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -288,4 +288,14 @@ func TestAuditWhitelistWithUnknownEntryErrors(t *testing.T) {
if _, err := a.Whitelist(context.Background(), "999", "exact"); err == nil {
t.Error("expected an error for an entry id that does not exist")
}
}
}
func TestAuditQueryMatchesIgnoresTheResultWindow(t *testing.T) {
q := AuditQuery{Tool: "bash", Limit: 1, Offset: 900}
entry := AuditEntry{ToolName: "Bash", Decision: "ALLOW", CreatedAt: "2026-01-01T00:00:00Z"}
if !q.Matches(entry) {
t.Error("Matches = false, want true: Limit and Offset window the result, they do not filter it")
}
if (AuditQuery{Tool: "bash"}).Matches(AuditEntry{ToolName: "Write"}) {
t.Error("Matches = true for a tool outside the filter, want false")
}
}
11 changes: 11 additions & 0 deletions packages/tui/audit/audit.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,10 @@ func nextToken() int { tokens++; return tokens }
const (
auditPage = 500
auditKeyColumn = 19
// exportPageSize is the largest page Audit.List honours. Exports walk pages
// of this size instead of trusting one oversized request, which used to cut
// the file off at the same number of rows.
exportPageSize = client.MaxAuditPage
)

var (
Expand Down Expand Up @@ -195,6 +199,13 @@ func (p *Audit) query() client.AuditQuery {
}
}

// filterActive reports whether the view shows only a subset of the log, i.e.
// whether "delete all" is meant to stop at the visible entries.
func (p *Audit) filterActive() bool {
return p.tool != "" || p.agent != "" || p.search != "" ||
auditDecisions[p.di] != "" || auditSignals[p.gi] != ""
}

func (p *Audit) pages(total int) int {
n := (total + auditPage - 1) / auditPage
if n < 1 {
Expand Down
68 changes: 53 additions & 15 deletions packages/tui/audit/audit_keys.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import (

tea "github.com/charmbracelet/bubbletea"

"github.com/solongate/agent-security/packages/core/client"
"github.com/solongate/agent-security/packages/core/config"
"github.com/solongate/agent-security/packages/tui"
)
Expand Down Expand Up @@ -192,24 +193,42 @@ func (p *Audit) editField(k tea.KeyMsg) tea.Cmd {

func (p *Audit) doDelete(kind string, cur logRow) tea.Cmd {
p.note = &auditNote{text: "deleting…", level: "ok"}
gen := p.gen
gen, q, filtered := p.gen, p.query(), p.filterActive()
return func() tea.Msg {
fail := func(err error) tea.Msg {
return auditNoteResult{gen: gen, note: auditNote{text: "✗ " + err.Error(), level: "bad"}}
}

n := 0
if kind == "one" {
switch {
case kind == "one":
n = tui.DeleteLocalEntry(cur.at, cur.tool, cur.session)
} else {
case !filtered:
// No filter is active, so "delete all" still means the whole local
// file — including lines that no longer parse. Keep that exact
// behaviour; filtering only narrows what the user asked to delete.
n = tui.ClearLocalLog()
default:
// Delete exactly what the view shows: the same query that produced the
// rows is the filter, so a user who narrowed the log cannot lose
// entries that were filtered out of sight.
n = tui.DeleteLocalLogMatching(func(raw []byte) bool {
e, ok := client.AuditEntryFromLocalLine(raw)
return ok && q.Matches(e)
})
}
if n == 0 {
return fail(errors.New("entry not found in the local file"))
if kind == "one" {
return fail(errors.New("entry not found in the local file"))
}
return fail(errors.New("no entry in the local file matched the current view"))
}
text := "✓ entry deleted"
if kind != "one" {
text = "✓ the whole log on this machine was deleted"
text = "✓ deleted " + strconv.Itoa(n) + " entries matching the active filter"
if !filtered {
text = "✓ the whole log on this machine was deleted"
}
}
return auditNoteResult{gen: gen, note: auditNote{text: text, level: "ok"},
reload: true, toTop: true}
Expand All @@ -226,18 +245,31 @@ func (p *Audit) doExport(kind string, pageRows []logRow) tea.Cmd {
note: auditNote{text: "✗ export failed: " + err.Error(), level: "bad"}}
}
rows := pageRows
truncatedAt := 0
if kind == "all" {

ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second)
defer cancel()
q.Limit, q.Offset = 10_000, 0
list, err := client.Audit.List(ctx, q)
if err != nil {
return fail(err)
}
rows = make([]logRow, 0, len(list.Entries))
for _, e := range list.Entries {
rows = append(rows, cloudRow(e))
// client.Audit.List clamps Limit to 1000, so one request per export
// silently cut every export above 1000 rows. Walk the pages instead
// and only stop when the matching set is complete.
rows = rows[:0]
for offset := 0; ; offset += exportPageSize {
q.Limit, q.Offset = exportPageSize, offset
list, err := client.Audit.List(ctx, q)
if err != nil {
return fail(err)
}
for _, e := range list.Entries {
rows = append(rows, cloudRow(e))
}
if len(rows) >= list.Total {
break
}
if len(list.Entries) < exportPageSize || ctx.Err() != nil {
truncatedAt = list.Total
break
}
}
sort.SliceStable(rows, func(i, j int) bool { return rows[i].at > rows[j].at })
}
Expand All @@ -262,7 +294,13 @@ func (p *Audit) doExport(kind string, pageRows []logRow) tea.Cmd {
}

_ = os.Chmod(file, 0o600)
return auditNoteResult{gen: gen, note: auditNote{
text: "✓ exported " + strconv.Itoa(len(rows)) + " rows → " + file, level: "ok"}}
text := "✓ exported " + strconv.Itoa(len(rows)) + " rows → " + file
level := "ok"
if truncatedAt > len(rows) {
text = "⚠ exported " + strconv.Itoa(len(rows)) + " of " + strconv.Itoa(truncatedAt) +
" matching rows (timed out) → " + file
level = "bad"
}
return auditNoteResult{gen: gen, note: auditNote{text: text, level: level}}
}
}
Loading
Loading