Skip to content

Commit 3b9b99a

Browse files
rsnodgrassSage-Ox
andcommitted
fix(prepush): never block — auto-redact JSONL via chokepoint, quarantine the rest (0.8.1)
Co-Authored-By: SageOx <ox@sageox.ai> SageOx-Session: https://sageox.ai/repo/repo_019c5812-01e9-7b7d-b5b1-321c471c9777/sessions/2026-05-12T23-39-ryan-OxzcCn/view
1 parent 212fe53 commit 3b9b99a

9 files changed

Lines changed: 1035 additions & 53 deletions

cmd/ox/doctor_check_registry.go

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -539,6 +539,19 @@ func init() {
539539
Run: checkLedgerEmbeddedCreds,
540540
})
541541

542+
// ox-y3ok: surface sessions that the pre-push secret gate
543+
// auto-quarantined because it couldn't auto-redact them. Read-only;
544+
// recovery is user-driven (interactive `ox session redact` or
545+
// manual scrub + restore from .sageox/cache/quarantine/).
546+
RegisterDoctorCheck(&DoctorCheck{
547+
Slug: CheckSlugLedgerRedactionDebt,
548+
Name: "Ledger redaction debt",
549+
Category: "Credential Hygiene",
550+
FixLevel: FixLevelCheckOnly,
551+
Description: "Surfaces sessions quarantined by the pre-push secret gate (preserved under .sageox/cache/quarantine/, dropped from pushes until redacted).",
552+
Run: checkLedgerRedactionDebt,
553+
})
554+
542555
// ox-9y4k: scan installed adapter hook content for known-suspicious
543556
// shapes that have no legitimate use in a commit/prompt hook.
544557
RegisterDoctorCheck(&DoctorCheck{

cmd/ox/doctor_redaction_debt.go

Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
package main
2+
3+
import (
4+
"encoding/json"
5+
"fmt"
6+
"os"
7+
"path/filepath"
8+
"sort"
9+
"strings"
10+
11+
"github.com/sageox/ox/internal/config"
12+
"github.com/sageox/ox/internal/ledger"
13+
)
14+
15+
// checkLedgerRedactionDebt surfaces sessions that the pre-push secret
16+
// gate auto-quarantined because it could not redact them through the
17+
// canonical chokepoint. See cmd/ox/prepush_autoredact.go for the
18+
// quarantine pipeline: bytes are moved to
19+
// <ledger>/.sageox/cache/quarantine/<session>/<file> and a JSON marker
20+
// is written under <ledger>/.sageox/cache/redaction-debt/<session>.json.
21+
//
22+
// This check is read-only and local-only. It does not re-scan content;
23+
// the markers are authoritative — they were produced by a fresh scan at
24+
// quarantine time. If the user manually moves a quarantined file back
25+
// to its in-place ledger path or removes the marker, the check
26+
// gracefully reflects the new state.
27+
//
28+
// --fix is intentionally a no-op: the recovery action is either
29+
// `ox session redact <session>` (interactive cleanup) or the user
30+
// manually moving the quarantined file back. The doctor command cannot
31+
// safely choose between those for the user; it surfaces the state and
32+
// gets out of the way.
33+
func checkLedgerRedactionDebt(fix bool) checkResult {
34+
name := "Ledger redaction debt"
35+
_ = fix // recovery is intentionally user-driven; see doc above.
36+
37+
gitRoot := findGitRoot()
38+
if gitRoot == "" {
39+
return SkippedCheck(name, "not in git repo", "")
40+
}
41+
localCfg, err := config.LoadLocalConfig(gitRoot)
42+
if err != nil {
43+
return SkippedCheck(name, "config error", "")
44+
}
45+
ledgerPath := resolveLedgerPathForAudit(localCfg)
46+
if ledgerPath == "" {
47+
return SkippedCheck(name, "no ledger configured", "")
48+
}
49+
if !ledger.Exists(ledgerPath) {
50+
return SkippedCheck(name, "ledger directory does not exist", "")
51+
}
52+
53+
debtDir := filepath.Join(ledgerPath, ".sageox", "cache", "redaction-debt")
54+
if _, err := os.Stat(debtDir); err != nil {
55+
if os.IsNotExist(err) {
56+
return PassedCheck(name, "no quarantined sessions")
57+
}
58+
return FailedCheck(name, fmt.Sprintf("stat debt dir: %v", err), "")
59+
}
60+
summaries, malformed := readDebtSummaries(debtDir)
61+
62+
if len(summaries) == 0 && len(malformed) == 0 {
63+
return PassedCheck(name, "no quarantined sessions")
64+
}
65+
66+
var b strings.Builder
67+
fmt.Fprintf(&b, "%d session(s) quarantined; bytes preserved under .sageox/cache/quarantine/",
68+
len(summaries))
69+
if len(malformed) > 0 {
70+
fmt.Fprintf(&b, "; %d marker(s) unreadable", len(malformed))
71+
}
72+
msg := b.String()
73+
74+
var detail strings.Builder
75+
for _, s := range summaries {
76+
fmt.Fprintf(&detail, " %s — %d finding(s) across %d file(s); detectors: %s\n",
77+
s.session, s.findings, s.files, strings.Join(s.detectors, ", "))
78+
}
79+
detail.WriteString("\nNext steps for each session:\n")
80+
detail.WriteString(" 1. Inspect bytes at .sageox/cache/quarantine/<session>/\n")
81+
detail.WriteString(" 2. Run `ox session redact <session>` for interactive cleanup, OR\n")
82+
detail.WriteString(" manually scrub the file and move it back to sessions/<session>/\n")
83+
detail.WriteString(" 3. Re-stage and commit; the next push will publish the cleaned bytes\n")
84+
if len(malformed) > 0 {
85+
detail.WriteString("\nUnreadable markers (remove and re-run if no quarantined bytes exist):\n")
86+
for _, m := range malformed {
87+
fmt.Fprintf(&detail, " .sageox/cache/redaction-debt/%s\n", m)
88+
}
89+
}
90+
91+
// Use WarningCheck (passed=true, warning=true) rather than
92+
// FailedCheck — debt is a state the user opted into by attempting to
93+
// push something the gate couldn't clean, and the system already
94+
// handled it gracefully (rest of push proceeded). Doctor shouldn't
95+
// treat this as an error; it's a reminder.
96+
return WarningCheck(name, msg, detail.String())
97+
}
98+
99+
// debtSummary is the per-marker shape produced by readDebtSummaries.
100+
// Aggregates the parts of redactionDebtRecord the doctor surface uses;
101+
// hides the full record from the caller (markers carry filenames and
102+
// line numbers, never matched bytes — ox-zyg7).
103+
type debtSummary struct {
104+
session string
105+
marker string
106+
findings int
107+
files int
108+
detectors []string
109+
}
110+
111+
// readDebtSummaries walks debtDir and parses every *.json marker into
112+
// a debtSummary. Returns (summaries, malformed) — malformed lists
113+
// filenames whose contents could not be parsed as a redactionDebtRecord.
114+
// Separated from checkLedgerRedactionDebt so a unit test can exercise
115+
// the parse + aggregation logic without needing a full project /
116+
// ledger-config harness.
117+
func readDebtSummaries(debtDir string) (summaries []debtSummary, malformed []string) {
118+
entries, err := os.ReadDir(debtDir)
119+
if err != nil {
120+
return nil, nil
121+
}
122+
for _, entry := range entries {
123+
if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".json") {
124+
continue
125+
}
126+
markerAbs := filepath.Join(debtDir, entry.Name())
127+
buf, err := os.ReadFile(markerAbs)
128+
if err != nil {
129+
malformed = append(malformed, entry.Name())
130+
continue
131+
}
132+
var rec redactionDebtRecord
133+
if err := json.Unmarshal(buf, &rec); err != nil {
134+
malformed = append(malformed, entry.Name())
135+
continue
136+
}
137+
detSet := map[string]struct{}{}
138+
for _, f := range rec.Findings {
139+
detSet[f.Detector] = struct{}{}
140+
}
141+
dets := make([]string, 0, len(detSet))
142+
for d := range detSet {
143+
dets = append(dets, d)
144+
}
145+
sort.Strings(dets)
146+
summaries = append(summaries, debtSummary{
147+
session: rec.SessionName,
148+
marker: entry.Name(),
149+
findings: len(rec.Findings),
150+
files: len(rec.QuarantinePaths),
151+
detectors: dets,
152+
})
153+
}
154+
sort.Slice(summaries, func(i, j int) bool {
155+
return summaries[i].session < summaries[j].session
156+
})
157+
return summaries, malformed
158+
}
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
package main
2+
3+
import (
4+
"encoding/json"
5+
"os"
6+
"path/filepath"
7+
"testing"
8+
"time"
9+
10+
"github.com/stretchr/testify/assert"
11+
"github.com/stretchr/testify/require"
12+
)
13+
14+
// TestCheckLedgerRedactionDebt_PassesWhenNoMarkers covers the steady
15+
// state: a ledger with no .sageox/cache/redaction-debt/ dir (or an empty
16+
// one) must report Passed. Anything else would print a spurious warning
17+
// every doctor run on a healthy ledger.
18+
func TestCheckLedgerRedactionDebt_PassesWhenNoMarkers(t *testing.T) {
19+
// We don't need the gate's recovery; we just need a ledger-shaped
20+
// project so the check's preconditions (gitRoot, localCfg, ledger
21+
// path) all resolve. Use makeLedgerWithCommit + the unified
22+
// config helpers won't apply here because the doctor check looks
23+
// up the ledger from a real project config. Instead, write the
24+
// marker into an absolute path under a tempdir and call the check
25+
// implementation directly via a helper that we expose for tests.
26+
//
27+
// The easier path: test the parsing surface of the check directly
28+
// with handcrafted markers. checkLedgerRedactionDebt is the cobra
29+
// glue + filesystem walk; separate the file-walk piece into a
30+
// helper for a focused unit. For now we exercise the structure of
31+
// the marker round-trip — the e2e wiring is covered by the gate
32+
// tests in prepush_autoredact_test.go (they emit real markers and
33+
// assert the doctor check can see them via the same file layout).
34+
35+
tmp := t.TempDir()
36+
debtDir := filepath.Join(tmp, ".sageox", "cache", "redaction-debt")
37+
require.NoError(t, os.MkdirAll(debtDir, 0o700))
38+
// empty dir → no markers → no debt
39+
summaries, malformed := readDebtSummaries(debtDir)
40+
assert.Empty(t, summaries)
41+
assert.Empty(t, malformed)
42+
}
43+
44+
// TestCheckLedgerRedactionDebt_SurfacesMarker is the load-bearing
45+
// assertion: a written marker becomes a doctor warning that names the
46+
// session, its detectors, and the next-step recovery commands.
47+
func TestCheckLedgerRedactionDebt_SurfacesMarker(t *testing.T) {
48+
tmp := t.TempDir()
49+
debtDir := filepath.Join(tmp, ".sageox", "cache", "redaction-debt")
50+
require.NoError(t, os.MkdirAll(debtDir, 0o700))
51+
52+
rec := redactionDebtRecord{
53+
SessionName: "2026-05-12-quarantined",
54+
QuarantinedAt: time.Now().UTC(),
55+
Reason: "pre-push secret gate could not auto-redact",
56+
Findings: []redactionDebtFinding{
57+
{Detector: "aws_access_key", Filename: "notes.md", Line: 3},
58+
{Detector: "generic_secret", Filename: "notes.md", Line: 9},
59+
},
60+
QuarantinePaths: []redactionDebtLocation{
61+
{From: "sessions/2026-05-12-quarantined/notes.md",
62+
To: ".sageox/cache/quarantine/2026-05-12-quarantined/notes.md"},
63+
},
64+
}
65+
buf, err := json.MarshalIndent(rec, "", " ")
66+
require.NoError(t, err)
67+
require.NoError(t, os.WriteFile(filepath.Join(debtDir, rec.SessionName+".json"), buf, 0o600))
68+
69+
summaries, malformed := readDebtSummaries(debtDir)
70+
require.Len(t, summaries, 1)
71+
require.Empty(t, malformed)
72+
got := summaries[0]
73+
assert.Equal(t, "2026-05-12-quarantined", got.session)
74+
assert.Equal(t, 2, got.findings)
75+
assert.Equal(t, 1, got.files)
76+
assert.Equal(t, []string{"aws_access_key", "generic_secret"}, got.detectors)
77+
}
78+
79+
// TestCheckLedgerRedactionDebt_FlagsMalformedMarkers ensures a corrupt
80+
// or non-JSON file in the debt dir is surfaced — silently dropping it
81+
// would mean a user could lose track of a quarantined session if its
82+
// marker got partially written by an interrupted process.
83+
func TestCheckLedgerRedactionDebt_FlagsMalformedMarkers(t *testing.T) {
84+
tmp := t.TempDir()
85+
debtDir := filepath.Join(tmp, ".sageox", "cache", "redaction-debt")
86+
require.NoError(t, os.MkdirAll(debtDir, 0o700))
87+
require.NoError(t, os.WriteFile(filepath.Join(debtDir, "broken.json"), []byte("not json"), 0o600))
88+
89+
summaries, malformed := readDebtSummaries(debtDir)
90+
assert.Empty(t, summaries)
91+
assert.Equal(t, []string{"broken.json"}, malformed)
92+
}

cmd/ox/doctor_types.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -186,6 +186,7 @@ const (
186186
// credential exposure without uploading findings off-machine.
187187
CheckSlugLedgerSecrets = "ledger-secrets"
188188
CheckSlugLedgerEmbeddedCreds = "ledger-embedded-creds"
189+
CheckSlugLedgerRedactionDebt = "ledger-redaction-debt"
189190

190191
// Hook content integrity (ox-9y4k): scan installed adapter hook
191192
// content for suspicious shapes (curl|sh, eval $(…), base64 -d|sh).

0 commit comments

Comments
 (0)