fix: address verified gadfly P4/#4 findings (audit/budget/persona)
executus CI / test (push) Failing after 1m4s

Security (all 3 models — HIGH): audit OnTool persisted raw tool args + results
verbatim for the very tools the OnStep narration-redaction flags as secret
(mcp_call/email_send/http_*) — the args/results are what CARRY the secret, so
they landed in skill_run_logs unredacted. Factored the predicate into
isSecretTool() (single source of truth) and OnTool now emits
args_redacted/result_redacted (+ lengths) for secret tools. Test asserts no
secret reaches the log. (persona) webhook_ip_allowlist entries are now
CIDR/IP-validated at load (malformed dropped + warned) instead of accepted raw.

Contract correctness (glm-5.2 + deepseek) — audit Memory now honors its
documented Storage contract: ListChildrenByParent/ListFinishedRunsBefore return
oldest-first; WalkParentChain returns root-first and honors MaxParentChainDepth;
ListRunsFiltered clamps limit (<=0 or >500 -> 50); ListFinishedRunsBefore with
limit<=0 returns none; an explicit RunFilter.Status (incl. "dry_run") matches
regardless of IncludeDryRun; LastRunBySkills counts only status=="ok" unless
includeFailed. (PurgeOlderThan's FinishedAt key is the SAFE behavior — in-flight
runs retained — so the doc was aligned to it, not the impl.)

Error-handling: appendLog now uses a bounded context (auditAppendTimeout=3s) so
a hung backend can't block the run goroutine on the hot path; Sink.StartRun
logs its (still best-effort) failure instead of swallowing it; budget Memory.Get
uses RLock (RWMutex); budget package doc fixed (was skillexec's); Check uses the
budgetWindow constant, not a duplicated literal.

Triaged false-positive: NewNoOpBudget returning BudgetTracker is assignable to
run.Budget (identical method sets) — no change needed.

Core go.sum still free of host/DB deps.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-26 23:44:34 -04:00
parent 2260480c81
commit d82cef46b4
8 changed files with 197 additions and 24 deletions
+34 -9
View File
@@ -99,6 +99,19 @@ func (m *Memory) newestFirst(keep func(SkillRun) bool) []SkillRun {
return out
}
// oldestFirst returns the retained runs in insertion (oldest-first) order,
// optionally filtered. Caller holds at least RLock.
func (m *Memory) oldestFirst(keep func(SkillRun) bool) []SkillRun {
out := make([]SkillRun, 0, len(m.order))
for _, id := range m.order {
r := m.runs[id]
if keep == nil || keep(r) {
out = append(out, r)
}
}
return out
}
func page(rs []SkillRun, offset, limit int) []SkillRun {
if offset < 0 {
offset = 0
@@ -142,10 +155,12 @@ func (m *Memory) ListRunsByCaller(_ context.Context, callerID string, limit int)
}
func (m *Memory) matchesFilter(r SkillRun, f RunFilter) bool {
if !f.IncludeDryRun && r.Status == "dry_run" {
return false
}
if f.Status != "" && r.Status != f.Status {
if f.Status != "" {
if r.Status != f.Status {
return false
}
// An explicit Status (even "dry_run") matches regardless of IncludeDryRun.
} else if !f.IncludeDryRun && r.Status == "dry_run" {
return false
}
if f.SkillID != "" && r.SkillID != f.SkillID {
@@ -170,6 +185,9 @@ func (m *Memory) matchesFilter(r SkillRun, f RunFilter) bool {
}
func (m *Memory) ListRunsFiltered(_ context.Context, f RunFilter, offset, limit int) ([]SkillRun, error) {
if limit <= 0 || limit > 500 {
limit = 50 // bound admin scans, per the Storage contract
}
m.mu.RLock()
defer m.mu.RUnlock()
return page(m.newestFirst(func(r SkillRun) bool { return m.matchesFilter(r, f) }), offset, limit), nil
@@ -203,7 +221,7 @@ func (m *Memory) PurgeOlderThan(_ context.Context, t time.Time) (int64, error) {
func (m *Memory) ListChildrenByParent(_ context.Context, parentRunID string) ([]SkillRun, error) {
m.mu.RLock()
defer m.mu.RUnlock()
return m.newestFirst(func(r SkillRun) bool { return r.ParentRunID == parentRunID }), nil
return m.oldestFirst(func(r SkillRun) bool { return r.ParentRunID == parentRunID }), nil
}
func (m *Memory) WalkParentChain(_ context.Context, runID string) ([]SkillRun, error) {
@@ -211,7 +229,7 @@ func (m *Memory) WalkParentChain(_ context.Context, runID string) ([]SkillRun, e
defer m.mu.RUnlock()
var chain []SkillRun
seen := map[string]bool{}
for id := runID; id != ""; {
for id := runID; id != "" && len(chain) < MaxParentChainDepth; {
r, ok := m.runs[id]
if !ok || seen[id] {
break
@@ -220,13 +238,20 @@ func (m *Memory) WalkParentChain(_ context.Context, runID string) ([]SkillRun, e
chain = append(chain, r)
id = r.ParentRunID
}
// Contract: root first, the queried run last. We walked child→root, so reverse.
for i, j := 0, len(chain)-1; i < j; i, j = i+1, j-1 {
chain[i], chain[j] = chain[j], chain[i]
}
return chain, nil
}
func (m *Memory) ListFinishedRunsBefore(_ context.Context, cutoff time.Time, limit int) ([]SkillRun, error) {
if limit <= 0 {
return nil, nil // contract: a real bound is required
}
m.mu.RLock()
defer m.mu.RUnlock()
return page(m.newestFirst(func(r SkillRun) bool {
return page(m.oldestFirst(func(r SkillRun) bool {
return r.FinishedAt != nil && r.FinishedAt.Before(cutoff)
}), 0, limit), nil
}
@@ -244,8 +269,8 @@ func (m *Memory) LastRunBySkills(_ context.Context, skillIDs []string, includeFa
if !want[r.SkillID] {
continue
}
if !includeFailed && (r.Status == "error" || r.Status == "timeout") {
continue
if !includeFailed && r.Status != "ok" {
continue // contract: only status=="ok" counts unless includeFailed
}
if r.StartedAt.After(out[r.SkillID]) {
out[r.SkillID] = r.StartedAt