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
2 changes: 2 additions & 0 deletions cmd/entire/cli/checkpoint/temporary.go
Original file line number Diff line number Diff line change
Expand Up @@ -895,6 +895,8 @@ func addDirectoryToEntriesWithAbsPath(repo *git.Repository, dirPathAbs, dirPathR

treePath := filepath.ToSlash(filepath.Join(dirPathRel, relWithinDir))

// Use redacted blob creation for metadata files (transcripts, prompts, etc.)
// to ensure PII and secrets are redacted before writing to git.
blobHash, mode, err := createRedactedBlobFromFile(repo, path, treePath)
if err != nil {
return fmt.Errorf("failed to create blob for %s: %w", path, err)
Expand Down
6 changes: 5 additions & 1 deletion cmd/entire/cli/doctor.go
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,11 @@ For each stuck session, you can choose to:
- Discard: Remove the session state and shadow branch data
- Skip: Leave the session as-is

Use --force to auto-fix all issues without prompting.`,
Use --force to condense all fixable sessions without prompting. Sessions that can't
be condensed will be discarded.`,
PreRun: func(_ *cobra.Command, _ []string) {
strategy.EnsureRedactionConfigured()
},
RunE: func(cmd *cobra.Command, _ []string) error {
return runSessionsFix(cmd, forceFlag)
},
Expand Down
4 changes: 4 additions & 0 deletions cmd/entire/cli/hooks_git_cmd.go
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,10 @@ func initHookLogging(ctx context.Context) func() {
// Init failed - logging will use stderr fallback
return func() {}
}

// Configure PII redaction once at startup (reads settings, no-op if disabled).
strategy.EnsureRedactionConfigured()

return logging.Close
}

Expand Down
96 changes: 96 additions & 0 deletions cmd/entire/cli/settings/settings.go
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,9 @@ type EntireSettings struct {
// nil = not asked yet (show prompt), true = opted in, false = opted out
Telemetry *bool `json:"telemetry,omitempty"`

// Redaction configures PII redaction behavior for transcripts and metadata.
Redaction *RedactionSettings `json:"redaction,omitempty"`

// CommitLinking controls how commits are linked to agent sessions.
// "always" = auto-link without prompting, "prompt" = ask on each commit.
// Defaults to "prompt" (preserves existing user behavior).
Expand All @@ -73,6 +76,21 @@ type EntireSettings struct {
Strategy string `json:"strategy,omitempty"`
}

// RedactionSettings configures redaction behavior beyond the default secret detection.
type RedactionSettings struct {
PII *PIISettings `json:"pii,omitempty"`
}

// PIISettings configures PII detection categories.
// When Enabled is true, email and phone default to true; address defaults to false.
type PIISettings struct {
Enabled bool `json:"enabled"`
Email *bool `json:"email,omitempty"`
Phone *bool `json:"phone,omitempty"`
Address *bool `json:"address,omitempty"`
CustomPatterns map[string]string `json:"custom_patterns,omitempty"`
}

// GetCommitLinking returns the effective commit linking mode.
// Returns the explicit value if set, otherwise defaults to "prompt"
// to preserve existing user behavior.
Expand Down Expand Up @@ -235,6 +253,16 @@ func mergeJSON(settings *EntireSettings, data []byte) error {
settings.Telemetry = &t
}

// Merge redaction sub-fields if present (field-level, not wholesale replace).
if redactionRaw, ok := raw["redaction"]; ok {
if settings.Redaction == nil {
settings.Redaction = &RedactionSettings{}
}
if err := mergeRedaction(settings.Redaction, redactionRaw); err != nil {
return fmt.Errorf("parsing redaction field: %w", err)
}
}

// Override commit_linking if present and non-empty
if commitLinkingRaw, ok := raw["commit_linking"]; ok {
var cl string
Expand Down Expand Up @@ -263,6 +291,74 @@ func mergeJSON(settings *EntireSettings, data []byte) error {
return nil
}

// mergeRedaction merges redaction overrides into existing RedactionSettings.
// Only fields present in the override JSON are applied.
func mergeRedaction(dst *RedactionSettings, data json.RawMessage) error {
var raw map[string]json.RawMessage
if err := json.Unmarshal(data, &raw); err != nil {
return fmt.Errorf("parsing redaction: %w", err)
}
if piiRaw, ok := raw["pii"]; ok {
if dst.PII == nil {
dst.PII = &PIISettings{}
}
if err := mergePIISettings(dst.PII, piiRaw); err != nil {
return err
}
}
return nil
}

// mergePIISettings merges PII overrides into existing PIISettings.
// Only fields present in the override JSON are applied; missing fields
// are preserved from the base settings.
func mergePIISettings(dst *PIISettings, data json.RawMessage) error {
var raw map[string]json.RawMessage
if err := json.Unmarshal(data, &raw); err != nil {
return fmt.Errorf("parsing pii: %w", err)
}
if v, ok := raw["enabled"]; ok {
if err := json.Unmarshal(v, &dst.Enabled); err != nil {
return fmt.Errorf("parsing pii.enabled: %w", err)
}
}
if v, ok := raw["email"]; ok {
var b bool
if err := json.Unmarshal(v, &b); err != nil {
return fmt.Errorf("parsing pii.email: %w", err)
}
dst.Email = &b
}
if v, ok := raw["phone"]; ok {
var b bool
if err := json.Unmarshal(v, &b); err != nil {
return fmt.Errorf("parsing pii.phone: %w", err)
}
dst.Phone = &b
}
if v, ok := raw["address"]; ok {
var b bool
if err := json.Unmarshal(v, &b); err != nil {
return fmt.Errorf("parsing pii.address: %w", err)
}
dst.Address = &b
}
if v, ok := raw["custom_patterns"]; ok {
var cp map[string]string
if err := json.Unmarshal(v, &cp); err != nil {
return fmt.Errorf("parsing pii.custom_patterns: %w", err)
}
if dst.CustomPatterns == nil {
dst.CustomPatterns = cp
} else {
for k, val := range cp {
dst.CustomPatterns[k] = val
}
}
}
return nil
}

// IsSetUp returns true if Entire has been set up in the current repository.
// This checks if .entire/settings.json exists.
// Use this to avoid creating files/directories in repos where Entire was never enabled.
Expand Down
131 changes: 131 additions & 0 deletions cmd/entire/cli/settings/settings_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ func TestLoad_AcceptsValidKeys(t *testing.T) {
"log_level": "debug",
"strategy_options": {"key": "value"},
"telemetry": true,
"redaction": {"pii": {"enabled": true, "email": true, "phone": false}},
"external_agents": true
}`
if err := os.WriteFile(settingsFile, []byte(settingsContent), 0644); err != nil {
Expand Down Expand Up @@ -90,6 +91,21 @@ func TestLoad_AcceptsValidKeys(t *testing.T) {
if settings.Telemetry == nil || !*settings.Telemetry {
t.Error("expected telemetry to be true")
}
if settings.Redaction == nil {
t.Fatal("expected redaction to be non-nil")
}
if settings.Redaction.PII == nil {
t.Fatal("expected redaction.pii to be non-nil")
}
if !settings.Redaction.PII.Enabled {
t.Error("expected redaction.pii.enabled to be true")
}
if settings.Redaction.PII.Email == nil || !*settings.Redaction.PII.Email {
t.Error("expected redaction.pii.email to be true")
}
if settings.Redaction.PII.Phone == nil || *settings.Redaction.PII.Phone {
t.Error("expected redaction.pii.phone to be false")
}
}

func TestLoad_LocalSettingsRejectsUnknownKeys(t *testing.T) {
Expand Down Expand Up @@ -133,6 +149,121 @@ func TestLoad_LocalSettingsRejectsUnknownKeys(t *testing.T) {
}
}

func TestLoad_MissingRedactionIsNil(t *testing.T) {
tmpDir := t.TempDir()
entireDir := filepath.Join(tmpDir, ".entire")
if err := os.MkdirAll(entireDir, 0o755); err != nil {
t.Fatalf("failed to create .entire directory: %v", err)
}

settingsFile := filepath.Join(entireDir, "settings.json")
if err := os.WriteFile(settingsFile, []byte(`{"enabled": true}`), 0o644); err != nil {
t.Fatalf("failed to write settings file: %v", err)
}
if err := os.MkdirAll(filepath.Join(tmpDir, ".git"), 0o755); err != nil {
t.Fatalf("failed to create .git directory: %v", err)
}
t.Chdir(tmpDir)

settings, err := Load(context.Background())
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if settings.Redaction != nil {
t.Error("expected redaction to be nil when not in settings")
}
}

func TestLoad_LocalOverridesRedaction(t *testing.T) {
tmpDir := t.TempDir()
entireDir := filepath.Join(tmpDir, ".entire")
if err := os.MkdirAll(entireDir, 0o755); err != nil {
t.Fatalf("failed to create .entire directory: %v", err)
}

// Base settings: PII disabled
settingsFile := filepath.Join(entireDir, "settings.json")
if err := os.WriteFile(settingsFile, []byte(`{"enabled": true, "redaction": {"pii": {"enabled": false}}}`), 0o644); err != nil {
t.Fatalf("failed to write settings file: %v", err)
}

// Local override: PII enabled with custom patterns
localFile := filepath.Join(entireDir, "settings.local.json")
localContent := `{"redaction": {"pii": {"enabled": true, "custom_patterns": {"employee_id": "EMP-\\d{6}"}}}}`
if err := os.WriteFile(localFile, []byte(localContent), 0o644); err != nil {
t.Fatalf("failed to write local settings file: %v", err)
}

if err := os.MkdirAll(filepath.Join(tmpDir, ".git"), 0o755); err != nil {
t.Fatalf("failed to create .git directory: %v", err)
}
t.Chdir(tmpDir)

settings, err := Load(context.Background())
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if settings.Redaction == nil || settings.Redaction.PII == nil {
t.Fatal("expected redaction.pii to be non-nil after local override")
}
if !settings.Redaction.PII.Enabled {
t.Error("expected local override to enable PII")
}
if settings.Redaction.PII.CustomPatterns == nil {
t.Fatal("expected custom_patterns to be non-nil")
}
if settings.Redaction.PII.CustomPatterns["employee_id"] != `EMP-\d{6}` {
t.Errorf("expected employee_id pattern, got %v", settings.Redaction.PII.CustomPatterns)
}
}

func TestLoad_LocalMergesRedactionSubfields(t *testing.T) {
tmpDir := t.TempDir()
entireDir := filepath.Join(tmpDir, ".entire")
if err := os.MkdirAll(entireDir, 0o755); err != nil {
t.Fatalf("failed to create .entire directory: %v", err)
}

// Base: PII enabled with email=true, phone=true
baseContent := `{"enabled":true,"redaction":{"pii":{"enabled":true,"email":true,"phone":true}}}`
if err := os.WriteFile(filepath.Join(entireDir, "settings.json"), []byte(baseContent), 0o644); err != nil {
t.Fatalf("failed to write settings file: %v", err)
}

// Local: adds custom_patterns only — should NOT erase email/phone from base
localContent := `{"redaction":{"pii":{"enabled":true,"custom_patterns":{"ssn":"\\d{3}-\\d{2}-\\d{4}"}}}}`
if err := os.WriteFile(filepath.Join(entireDir, "settings.local.json"), []byte(localContent), 0o644); err != nil {
t.Fatalf("failed to write local settings file: %v", err)
}

if err := os.MkdirAll(filepath.Join(tmpDir, ".git"), 0o755); err != nil {
t.Fatalf("failed to create .git directory: %v", err)
}
t.Chdir(tmpDir)

settings, err := Load(context.Background())
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if settings.Redaction == nil || settings.Redaction.PII == nil {
t.Fatal("expected redaction.pii to be non-nil")
}
// email and phone from base should survive local merge
if settings.Redaction.PII.Email == nil || !*settings.Redaction.PII.Email {
t.Error("expected email=true from base to survive local merge")
}
if settings.Redaction.PII.Phone == nil || !*settings.Redaction.PII.Phone {
t.Error("expected phone=true from base to survive local merge")
}
// custom_patterns from local should be present
if settings.Redaction.PII.CustomPatterns == nil {
t.Fatal("expected custom_patterns from local to be present")
}
if _, ok := settings.Redaction.PII.CustomPatterns["ssn"]; !ok {
t.Error("expected ssn pattern from local override")
}
}

func TestLoad_AcceptsDeprecatedStrategyField(t *testing.T) {
tmpDir := t.TempDir()

Expand Down
34 changes: 34 additions & 0 deletions cmd/entire/cli/strategy/common.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,9 @@ import (
"github.com/entireio/cli/cmd/entire/cli/checkpoint/id"
"github.com/entireio/cli/cmd/entire/cli/logging"
"github.com/entireio/cli/cmd/entire/cli/paths"
"github.com/entireio/cli/cmd/entire/cli/settings"
"github.com/entireio/cli/cmd/entire/cli/trailers"
"github.com/entireio/cli/redact"

"github.com/go-git/go-git/v6"
"github.com/go-git/go-git/v6/plumbing"
Expand Down Expand Up @@ -275,6 +277,38 @@ var (
protectedDirsCache []string
)

var initRedactionOnce sync.Once

// EnsureRedactionConfigured loads PII redaction settings and configures the
// redact package. No-op if PII is not enabled in settings.
// Must be called at each process entry point before checkpoint writes
// (e.g., hook PersistentPreRunE, doctor PreRun).
func EnsureRedactionConfigured() {
initRedactionOnce.Do(func() {
ctx := context.Background()
s, err := settings.Load(ctx)
if err != nil {
logCtx := logging.WithComponent(ctx, "redaction")
logging.Warn(logCtx, "failed to load settings for PII redaction", slog.String("error", err.Error()))
return
}
if s.Redaction == nil || s.Redaction.PII == nil || !s.Redaction.PII.Enabled {
return
}
Comment thread
peyton-alt marked this conversation as resolved.
pii := s.Redaction.PII
cfg := redact.PIIConfig{
Enabled: true,
Categories: make(map[redact.PIICategory]bool),
CustomPatterns: pii.CustomPatterns,
}
// Email and phone default to true when PII is enabled; address defaults to false.
cfg.Categories[redact.PIIEmail] = pii.Email == nil || *pii.Email
cfg.Categories[redact.PIIPhone] = pii.Phone == nil || *pii.Phone
cfg.Categories[redact.PIIAddress] = pii.Address != nil && *pii.Address
redact.ConfigurePII(cfg)
})
}

// resolveAgentType picks the best agent type from the context and existing state.
// Priority: existing state > context value.
func resolveAgentType(ctxAgentType types.AgentType, state *SessionState) types.AgentType {
Expand Down
Loading
Loading