Compare commits

..
Author SHA1 Message Date
Vasanthdev2004 45cec9c6cf test(search): make the metadata coverage guard recurse, so nesting cannot slip past it
The first version of the guard walked only the direct string fields of
sessions.Metadata and hand-allocated the Goal beside it. That left it with the
blind spot it exists to close: a nested struct or pointer field added later
would arrive empty, the walk would find nothing in it, and the test would pass
while the new field went out unredacted. Raised by CodeRabbit on the PR.

Filling is now recursive through structs, pointers and slices, allocating what
it finds nil, and the walk is its mirror. The test compares the two counts, so a
shape one traversal understands and the other does not fails as SETUP INVALID
rather than passing quietly.

Both helpers fail rather than skip on a kind they do not understand. A field the
guard cannot populate is a field it is not guarding, and saying so at the point
it is added is the whole value of having it.
2026-09-24 11:34:58 +05:30
Vasanthdev2004 347db49978 fix(search): redact every text field of session metadata, not ten of thirty
`zero search --json` prints Hit.Session straight to stdout and redactMetadata
covered ten of its string fields. Filling every string-kinded field of
sessions.Metadata with a credential and walking the result names twenty that
came back carrying it: SessionKind, Tag, RootSessionID, AgentName, TaskID,
SpawnedFromEventID, SpecID, SpecFilePath, SpecStatus, SpecDraftModelID,
SpecDraftReasoning, SpecUserComment, SpecRejectReason, SpecSourceSessionID,
SpecImplSessionID, and the five on the Goal (Objective, Status, StatusReason,
CreatedAt, UpdatedAt).

The ones carrying the most free text are the ones a hand-kept list is least
likely to reach: SpecUserComment and SpecRejectReason are review text the user
typed, SpecDraftReasoning is the model's own prose, and Goal.Objective and
Goal.StatusReason are however the goal was phrased.

Goal is redacted through a copy. Metadata is passed by value but Goal is a
pointer inside it, so redacting in place would edit the session the caller still
holds, and a search would quietly rewrite the live record it was only asked to
report on.

TestRedactMetadataCoversEveryTextField walks the struct rather than repeating
the list, so a string field added to Metadata or Goal later fails on the day it
lands. It carries a SETUP INVALID guard that the chosen secret is a shape
RedactString actually recognizes, since otherwise every leg would pass
vacuously, and a second guard that the walk visited any fields at all.
2026-09-24 09:57:23 +05:30
Anandan 99721c762f fix(providers): send stable session headers to OpenCode (#1060) 2026-09-16 07:58:40 +08:00
2 changed files with 198 additions and 0 deletions

No files matched your search

+42
View File
@@ -246,20 +246,62 @@ func RedactResult(result Result) Result {
return redacted
}
// redactMetadata redacts EVERY text field of the metadata, not the subset a
// reader happens to think is user-facing. `zero search --json` prints this
// straight to stdout, and the fields carrying the most free text are the ones
// least likely to be on a hand-kept list: SpecUserComment and SpecRejectReason
// are review text the user typed, SpecDraftReasoning is the model's own prose,
// and Goal.Objective and Goal.StatusReason are whatever the goal was phrased
// as. A field added to sessions.Metadata later is covered by
// TestRedactMetadataCoversEveryTextField, which walks the struct rather than
// repeating this list, so the two cannot drift apart silently.
func redactMetadata(session sessions.Metadata, options redaction.Options) sessions.Metadata {
session.SessionID = redaction.RedactString(session.SessionID, options)
session.SessionKind = sessions.SessionKind(redaction.RedactString(string(session.SessionKind), options))
session.Title = redaction.RedactString(session.Title, options)
session.Cwd = redaction.RedactString(session.Cwd, options)
session.ModelID = redaction.RedactString(session.ModelID, options)
session.Provider = redaction.RedactString(session.Provider, options)
session.Tag = redaction.RedactString(session.Tag, options)
session.ParentSessionID = redaction.RedactString(session.ParentSessionID, options)
session.RootSessionID = redaction.RedactString(session.RootSessionID, options)
session.AgentName = redaction.RedactString(session.AgentName, options)
session.TaskID = redaction.RedactString(session.TaskID, options)
session.ForkedFromEventID = redaction.RedactString(session.ForkedFromEventID, options)
session.SpawnedFromEventID = redaction.RedactString(session.SpawnedFromEventID, options)
session.SpecID = redaction.RedactString(session.SpecID, options)
session.SpecFilePath = redaction.RedactString(session.SpecFilePath, options)
session.SpecStatus = sessions.SpecStatus(redaction.RedactString(string(session.SpecStatus), options))
session.SpecDraftModelID = redaction.RedactString(session.SpecDraftModelID, options)
session.SpecDraftReasoning = redaction.RedactString(session.SpecDraftReasoning, options)
session.SpecUserComment = redaction.RedactString(session.SpecUserComment, options)
session.SpecRejectReason = redaction.RedactString(session.SpecRejectReason, options)
session.SpecSourceSessionID = redaction.RedactString(session.SpecSourceSessionID, options)
session.SpecImplSessionID = redaction.RedactString(session.SpecImplSessionID, options)
session.CreatedAt = redaction.RedactString(session.CreatedAt, options)
session.UpdatedAt = redaction.RedactString(session.UpdatedAt, options)
session.LastEventType = sessions.EventType(redaction.RedactString(string(session.LastEventType), options))
session.Goal = redactGoal(session.Goal, options)
return session
}
// redactGoal returns a redacted COPY. Metadata is passed by value, but Goal is
// a pointer inside it, so redacting through it would rewrite the goal the
// caller still holds: a search would quietly edit the live session record it
// was only supposed to report on.
func redactGoal(goal *sessions.Goal, options redaction.Options) *sessions.Goal {
if goal == nil {
return nil
}
redacted := *goal
redacted.Objective = redaction.RedactString(redacted.Objective, options)
redacted.Status = sessions.GoalStatus(redaction.RedactString(string(redacted.Status), options))
redacted.StatusReason = redaction.RedactString(redacted.StatusReason, options)
redacted.CreatedAt = redaction.RedactString(redacted.CreatedAt, options)
redacted.UpdatedAt = redaction.RedactString(redacted.UpdatedAt, options)
return &redacted
}
func redactEventSummary(event EventSummary, options redaction.Options) EventSummary {
event.ID = redaction.RedactString(event.ID, options)
event.Type = sessions.EventType(redaction.RedactString(string(event.Type), options))
+156
View File
@@ -1,12 +1,15 @@
package search
import (
"fmt"
"os"
"path/filepath"
"reflect"
"strings"
"testing"
"time"
"github.com/Gitlawb/zero/internal/redaction"
"github.com/Gitlawb/zero/internal/sessions"
)
@@ -107,3 +110,156 @@ func fixedSearchClock(value string) func() time.Time {
}
return func() time.Time { return parsed }
}
// EVERY TEXT FIELD, NOT A HAND-KEPT SUBSET.
//
// `zero search --json` prints Hit.Session straight to stdout, so a field
// redactMetadata forgets is a field that reaches the terminal with whatever was
// in it. The list in redactMetadata is written out by hand; this walks the
// struct instead, so a string field added to sessions.Metadata (or to the Goal
// hanging off it) fails here on the day it lands rather than the day someone
// notices it in output. It named twenty uncovered fields when it was written,
// including the review text the user types and the model's own draft reasoning.
func TestRedactMetadataCoversEveryTextField(t *testing.T) {
const secret = "sk-ant-api03-aaaaaaaaaaaaaaaaaaaaaaaa0123456789ABCD"
// The premise: the secret is one RedactString already recognizes, so a field
// that still carries it was not passed through the redactor at all.
if redaction.RedactString(secret, redaction.Options{}) == secret {
t.Fatalf("SETUP INVALID: %q is not a shape RedactString redacts, so every leg below passes vacuously", secret)
}
var session sessions.Metadata
filled := fillMetadataText(t, reflect.ValueOf(&session).Elem(), secret, 0)
var leaked []string
var checked int
walkMetadataText(t, reflect.ValueOf(redactMetadata(session, redaction.Options{})), "", 0, func(name, value string) {
checked++
if strings.Contains(value, secret) {
leaked = append(leaked, name)
}
})
// Filling and walking are separate traversals of the same shape, so a field
// one of them understands and the other does not would otherwise show up as
// a quiet pass rather than a failure.
if checked != filled {
t.Fatalf("SETUP INVALID: filled %d text fields and walked %d, so the two traversals disagree about the shape of Metadata", filled, checked)
}
if filled == 0 {
t.Fatal("SETUP INVALID: no text fields were populated, so this proves nothing")
}
if len(leaked) > 0 {
t.Errorf("redactMetadata left the secret in %d of %d text fields: %s",
len(leaked), checked, strings.Join(leaked, ", "))
}
}
// fillMetadataText writes text into every text-bearing field reachable from
// value, allocating nil pointers and one-element slices on the way, and returns
// how many strings it set.
//
// Recursive on purpose. The first version of this walked only the direct string
// fields of one struct and hand-allocated the Goal next to it, which left the
// guard with the same blind spot it exists to close: a nested struct added to
// sessions.Metadata later would arrive empty, the walk would find nothing in it
// and the test would pass while the new field went unredacted. Raised by
// CodeRabbit on the PR.
//
// It fails rather than skips on a kind it does not understand, because a field
// this cannot populate is a field the guard is not guarding.
func fillMetadataText(t *testing.T, value reflect.Value, text string, depth int) int {
t.Helper()
if depth > 6 {
t.Fatalf("fillMetadataText recursed past depth %d, which suggests a cycle in the type", depth)
}
if !value.CanSet() {
return 0
}
switch value.Kind() {
case reflect.String:
value.SetString(text)
return 1
case reflect.Pointer:
if value.IsNil() {
value.Set(reflect.New(value.Type().Elem()))
}
return fillMetadataText(t, value.Elem(), text, depth+1)
case reflect.Struct:
filled := 0
for index := 0; index < value.NumField(); index++ {
if !value.Type().Field(index).IsExported() {
continue
}
filled += fillMetadataText(t, value.Field(index), text, depth+1)
}
return filled
case reflect.Slice:
value.Set(reflect.MakeSlice(value.Type(), 1, 1))
return fillMetadataText(t, value.Index(0), text, depth+1)
case reflect.Bool, reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64,
reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64,
reflect.Float32, reflect.Float64:
return 0
default:
t.Fatalf("fillMetadataText does not know how to populate %s (kind %s); teach it and walkMetadataText together, or this guard stops covering that field",
value.Type(), value.Kind())
return 0
}
}
// walkMetadataText visits every text-bearing field reachable from value, in the
// same shape fillMetadataText populates. The two are deliberately symmetric and
// the caller compares their counts.
func walkMetadataText(t *testing.T, value reflect.Value, prefix string, depth int, visit func(name, value string)) {
t.Helper()
if depth > 6 {
t.Fatalf("walkMetadataText recursed past depth %d, which suggests a cycle in the type", depth)
}
switch value.Kind() {
case reflect.String:
visit(prefix, value.String())
case reflect.Pointer:
if !value.IsNil() {
walkMetadataText(t, value.Elem(), prefix, depth+1, visit)
}
case reflect.Struct:
for index := 0; index < value.NumField(); index++ {
field := value.Type().Field(index)
if !field.IsExported() {
continue
}
name := field.Name
if prefix != "" {
name = prefix + "." + name
}
walkMetadataText(t, value.Field(index), name, depth+1, visit)
}
case reflect.Slice:
for index := 0; index < value.Len(); index++ {
walkMetadataText(t, value.Index(index), fmt.Sprintf("%s[%d]", prefix, index), depth+1, visit)
}
}
}
// Metadata is passed by value, but Goal is a pointer inside it. Redacting
// through that pointer would edit the session the caller still holds, so a
// search would quietly rewrite the live record it was asked to report on.
func TestRedactMetadataDoesNotRewriteTheCallersGoal(t *testing.T) {
const secret = "sk-ant-api03-aaaaaaaaaaaaaaaaaaaaaaaa0123456789ABCD"
goal := &sessions.Goal{Objective: "ship " + secret, StatusReason: "blocked on " + secret}
session := sessions.Metadata{SessionID: "s", Goal: goal}
redacted := redactMetadata(session, redaction.Options{})
if goal.Objective != "ship "+secret || goal.StatusReason != "blocked on "+secret {
t.Errorf("redactMetadata rewrote the caller's goal: %+v", *goal)
}
if redacted.Goal == goal {
t.Error("redacted metadata still points at the caller's goal")
}
if strings.Contains(redacted.Goal.Objective, secret) || strings.Contains(redacted.Goal.StatusReason, secret) {
t.Errorf("goal text was not redacted: %+v", *redacted.Goal)
}
}