mirror of
https://github.com/Twigpine/zero.git
synced 2026-11-08 09:49:34 -10:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
daabe11164 |
No files matched your search
@@ -246,62 +246,20 @@ 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))
|
||||
|
||||
@@ -1,15 +1,12 @@
|
||||
package search
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/redaction"
|
||||
"github.com/Gitlawb/zero/internal/sessions"
|
||||
)
|
||||
|
||||
@@ -110,156 +107,3 @@ 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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user