mirror of
https://github.com/Twigpine/zero.git
synced 2026-11-09 18:08:31 -10:00
* docs(audit): 2026-06-20 deep adversarial audit report
Audit-only deliverable (no source changed). Fresh origin/main (bfbdbb1) deep
read across 13 subsystems with per-finding adversarial re-verification, plus
reconciliation of the 2026-06-10/2026-06-13 audits.
Result: build/vet clean; race detector finds 0 data races (67/68 pkgs; the
3 internal/tools failures are -race-only timing flakes). 35 surviving findings
(1 high, 15 medium, 17 low, 2 info); 64 prior findings fixed, 14 partial,
12 still-open.
* fix(providerhealth): strip credentials on cross-host redirect (AUDIT-M10)
The connectivity probe's CheckRedirect re-validated the redirect host against
the SSRF blocklist but never stripped auth headers. Go's stdlib only auto-strips
Authorization/Cookie/WWW-Authenticate on a host change — not x-api-key,
x-goog-api-key, or custom provider headers — so a baseURL that 3xx-redirects to
an attacker-controlled public host received the provider API key verbatim.
Thread the exact set of auth-bearing header names (kind default + profile
AuthHeader + CustomHeaders keys + well-known) into the client and Header.Del
them whenever the redirect target's host:port differs from the original.
Regression test asserts the creds are dropped cross-host and kept same-host.
* fix(sessions): fsync events.jsonl append for crash durability (AUDIT-M12)
appendEventLocked wrote the event into the page cache and returned success, then
durably wrote (fsync'd) the derived metadata. A crash after the metadata fsync
but before the events.jsonl page flushed left metadata.EventCount ahead of the
log — silently dropping the last appended event, including the checkpoint a
/rewind targets. Add file.Sync() before Close so the log is at least as durable
as the metadata derived from it.
(Note: the fsync itself has no portable unit-test seam; covered by the existing
sessions append/durability suite for no-regression.)
* fix(cron): DST fall-back double-fire + claim double-grant (AUDIT-M4, AUDIT-M5)
M4: fireJob/claimFire computed the next slot from the raw sub-second now(), so
schedule.Next's DST fall-back collapse guard (which only engages for a
minute-aligned 'after') never fired — a daily job in the repeated wall-clock hour
ran twice on the fall-back day. Feed sched.Next a minute-truncated instant at the
claim and the post-exec advance (the due-check still uses the real instant so
sub-minute NextRunAt jobs are unaffected).
M5: claimFire left NextRunAt unchanged when the schedule couldn't advance
(unparseable/exhausted spec), so two concurrent schedulers both saw the job due
and both fired it. Pause the job inside the same locked claim instead: the winner
still fires once and the post-exec keeps it paused; the loser sees a non-active
job and is refused.
Tests: claimFire double-claim on an unadvanceable job (second refused, job paused)
and a DST fall-back daily job advancing past the repeat (not re-firing it).
* fix(daemon): bound the control handshake + track bridge conns for Shutdown (AUDIT-M7, M8, I1)
The remote bridge clears the connection deadline before handing the conn to
Server.ServeConn, and the local socket set none, so handleConn's two ReadControl
handshake calls could block forever — an authenticated-but-idle remote peer
pinned a bridge connection slot (32 of them exhaust the bridge), and the local
path had the same unbounded read. Set a handshake deadline in handleConn and
clear it before the (long-lived) streaming dispatch (M7, I1).
ServeConn also bypassed trackConn, so Shutdown's conns-close sweep couldn't close
a remote conn stalled in that handshake read. Track/untrack it like the local
accept loop does (M8).
Tests: handleConn returns under a shortened handshake deadline when the peer
never sends hello; Shutdown closes a ServeConn'd conn well under the 10s deadline.
* fix(swarm): atomic rename-with-verify mailbox stale-lock break (AUDIT-M13)
The stale-break compared two consecutive reads of the SAME lock file
(string(data) == string(stale)), so the equality was always true and gave no
protection: two waiters could both observe a stale lock and both os.Remove it
while a third recreated it via O_EXCL, leaving two live writers to the same
per-agent inbox (split-brain). Replace with the proven rename-with-verify used by
the cron/hooks/oauth locks: rename the stale file aside (only one racer wins the
rename), re-check the moved file's mtime is still stale before deleting, and
rename it back if a holder rotated a fresh lock in the gap.
Tests: a genuinely stale lock is reclaimed (no .stale.* leftover); a fresh held
lock is never broken. (The double-break race itself is not deterministically
unit-testable; the fix mirrors the cron/hooks/oauth pattern.)
* fix(streamjson): anchor api_key redaction so prose isn't mangled (AUDIT-M1)
The api_key secret pattern used ["'=:\s]+ as the delimiter class, so a bare
space after the marker counted — 'the api_key: foo setting' and 'apiKey value
spans' had the following word replaced with [REDACTED] in every emitted
stream-json event (text deltas, tool output, final answer). Require a real
assignment delimiter (= or :) and a credential-length body, mirroring the
already-anchored sk-/bearer patterns. Prose survives; real api_key=... secrets
still redact.
* fix(tools): snapshot exec-session lastUsedAt under its lock to fix prune race (AUDIT-L15)
sessionToPruneLocked sorted on execSession.lastUsedAt while holding only
manager.mu, but touch() writes lastUsedAt under session.mu — a data race on a
time.Time that could torn-read and mis-select the prune victim. Snapshot each
session's lastUsedAt via a session.mu-guarded getter before sorting (lock order
stays manager.mu -> session.mu; no path takes them the other way). Adds a -race
regression test driving touch() concurrently with the prune selection.
* fix(swarm): release per-job context on MaxRuns completion (AUDIT-L13)
Scheduler.run returned on MaxRuns completion without calling job.cancel(), so the
job's derived context (and the goroutine context.WithCancel spawns to propagate
parent cancellation) leaked until the whole scheduler was cancelled at session
end. Add defer job.cancel() (idempotent with Cancel/Close) so it is released on
every exit path.
* fix(agent): route post-compaction and max-turns connects through streamWithReconnect (AUDIT-L1)
The main turn connect already retries transient disconnects via
streamWithReconnect, but the post-compaction retry connect, the reactive
mid-stream reconnect, and finalAnswerAfterMaxTurns called provider.StreamCompletion
directly — a single transient hiccup on those (e.g. the final summary after a long
autonomous/cron run, or the turn right after a context-limit compaction) failed the
whole run with no retry. All three are pre-content connects, so routing them through
the helper carries no OnText-duplication risk.
* fix(cli): reject trailing args after --skip-permissions-unsafe (AUDIT-L3)
zero --skip-permissions-unsafe "fix bug" silently discarded the prompt and
launched the interactive TUI, so a scripted one-shot unsafe invocation appeared
to hang. Reject any trailing positional arg loudly (after checking for a
misplaced --add-dir first, which keeps its specific error), pointing users at
the working form: zero exec --skip-permissions-unsafe -p "...".
* fix(provideroauth): error on present-but-wrong-shape chatgpt account-id claim (AUDIT-L11)
extractChatGPTAccountID returned ("", nil) both when the id_token had no
account-id claim AND when OpenAI's auth namespace was present but the account id
was missing/empty/non-string. The latter is a claim-shape change: the caller only
warns on a non-nil error, so it silently omitted the chatgpt-account-id header and
every Codex call 401'd with no diagnostic, indistinguishable from an expired
token. Return an error for the present-but-unusable case so the login warning
fires; a genuinely absent claim still soft-skips ("", nil).
* test(tools): widen exec-session poll windows so the suite is -race-clean (AUDIT report §2)
Three exec-session timing tests assumed a 250ms-sleep / immediate child would
finish within a fixed 1000ms yield. Under -race the child (the race-instrumented
test binary) starts slowly enough to overrun it, so the tool correctly returned a
still-running session_id instead of an exit_code and the asserts failed — the
non-green -race suite the audit flagged in §2. collect() returns as soon as the
session completes, so widening the poll window to 30s makes them deterministic
under -race without slowing the passing case. Test-only; no behavior change.
* docs(audit): mark remediation status (fixed/deferred/skipped) for the 2026-06-20 audit
* docs(audit): strip trailing whitespace from the report (fix git diff --check)
* fix(tools): drain PTY output before marking an exec session done (AUDIT-M14)
A TTY exec session's stdout was copied into the buffer by a fire-and-forget
io.Copy goroutine, but startSession's Wait goroutine called markDone (and then
removed the session) as soon as command.Wait() returned — before that goroutine
had necessarily drained the master FD. The command's final output chunk (e.g. a
test runner's last PASS/FAIL line) could be lost on exit, and since the exited
session is removed it could not be polled back. This also surfaced as the flaky
TestExecCommandTTYSessionAcceptsInputOnLinux on loaded CI.
Make the PTY cleanup (called after command.Wait, before markDone) join the copy
goroutine before closing the master: the child has exited so the master EOFs once
io.Copy finishes draining; waiting for that (bounded by bashWaitDelay) guarantees
all output is buffered before the session is marked done. Closing the master
first would truncate the unread tail, so the join precedes the Close.
Linux-only path (exec_pty_linux.go); verified via GOOS=linux build+vet (the TTY
test only runs on linux/CI).
* docs(audit): mark M14 fixed (PTY drain) in remediation status
---------
Co-authored-by: gnanam1990 <[email protected]>
202 lines
8.4 KiB
Go
202 lines
8.4 KiB
Go
package cli
|
|
|
|
import (
|
|
"bytes"
|
|
"io"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/Gitlawb/zero/internal/cron"
|
|
)
|
|
|
|
// fakeExec records each invocation and returns a fixed exit code.
|
|
type fakeExec struct {
|
|
mu sync.Mutex
|
|
calls [][]string
|
|
code int
|
|
}
|
|
|
|
func (f *fakeExec) run(args []string, stdout, stderr io.Writer) int {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
f.calls = append(f.calls, args)
|
|
return f.code
|
|
}
|
|
|
|
func TestCronRunOnceFiresDueJobs(t *testing.T) {
|
|
now := time.Date(2026, 6, 9, 9, 0, 0, 0, time.UTC)
|
|
store := cron.NewStore(cron.StoreOptions{RootDir: t.TempDir(), Now: func() time.Time { return now }})
|
|
due, _ := store.Add(cron.Job{Expr: "0 9 * * *", Prompt: "fire me", Status: cron.StatusActive, NextRunAt: now.Add(-time.Minute)})
|
|
notDue, _ := store.Add(cron.Job{Expr: "0 9 * * *", Prompt: "later", Status: cron.StatusActive, NextRunAt: now.Add(time.Hour)})
|
|
paused, _ := store.Add(cron.Job{Expr: "0 9 * * *", Prompt: "paused", Status: cron.StatusPaused, NextRunAt: now.Add(-time.Hour)})
|
|
|
|
fx := &fakeExec{}
|
|
var out, errb bytes.Buffer
|
|
code := cronRun(store, func() time.Time { return now }, []string{"--once"}, &out, &errb, fx.run)
|
|
if code != 0 {
|
|
t.Fatalf("run --once exit=%d err=%s", code, errb.String())
|
|
}
|
|
if len(fx.calls) != 1 {
|
|
t.Fatalf("expected exactly 1 fire (the due job), got %d: %v", len(fx.calls), fx.calls)
|
|
}
|
|
args := fx.calls[0]
|
|
if args[0] != "exec" || !contains(args, "--prompt=fire me") {
|
|
t.Fatalf("fire must shell exec with inline --prompt=: %v", args)
|
|
}
|
|
if !contains(args, "--output-format") || !contains(args, "stream-json") {
|
|
t.Fatalf("fire must use stream-json for session persistence: %v", args)
|
|
}
|
|
// due job rescheduled forward + fireCount incremented + run recorded
|
|
d, _ := store.Get(due.ID)
|
|
if d.FireCount != 1 || !d.NextRunAt.After(now) {
|
|
t.Fatalf("due job not advanced: %+v", d)
|
|
}
|
|
runs, _ := store.Runs(due.ID)
|
|
if len(runs) != 1 {
|
|
t.Fatalf("expected 1 run record, got %d", len(runs))
|
|
}
|
|
if r, _ := store.Get(notDue.ID); r.FireCount != 0 {
|
|
t.Fatal("not-due job must not fire")
|
|
}
|
|
if r, _ := store.Get(paused.ID); r.FireCount != 0 {
|
|
t.Fatal("paused job must not fire")
|
|
}
|
|
}
|
|
|
|
func TestCronRunOnceSkipsOverdueWithoutCatchUp(t *testing.T) {
|
|
now := time.Date(2026, 6, 9, 9, 0, 0, 0, time.UTC)
|
|
store := cron.NewStore(cron.StoreOptions{RootDir: t.TempDir(), Now: func() time.Time { return now }})
|
|
// NextRunAt far in the past, but we still fire once because it's due; the
|
|
// distinction this test pins: after firing, it reschedules to a FUTURE slot.
|
|
job, _ := store.Add(cron.Job{Expr: "0 9 * * *", Prompt: "x", Status: cron.StatusActive, NextRunAt: now.Add(-72 * time.Hour)})
|
|
fx := &fakeExec{}
|
|
var out, errb bytes.Buffer
|
|
cronRun(store, func() time.Time { return now }, []string{"--once"}, &out, &errb, fx.run)
|
|
d, _ := store.Get(job.ID)
|
|
if !d.NextRunAt.After(now) {
|
|
t.Fatalf("after firing, next run must be in the future, got %v", d.NextRunAt)
|
|
}
|
|
}
|
|
|
|
func TestClaimFireRefusesSecondClaimOnUnadvanceableJob(t *testing.T) {
|
|
// AUDIT-M5: two concurrent schedulers must not BOTH claim the same fire of a
|
|
// job whose schedule cannot advance. The first claim pauses it inside the lock;
|
|
// the second sees a non-active job and is refused.
|
|
now := time.Date(2026, 6, 9, 9, 0, 0, 0, time.UTC)
|
|
store := cron.NewStore(cron.StoreOptions{RootDir: t.TempDir(), Now: func() time.Time { return now }})
|
|
job, err := store.Add(cron.Job{Expr: "0 0 30 2 *", Prompt: "x", Status: cron.StatusActive, NextRunAt: now.Add(-time.Minute)})
|
|
if err != nil {
|
|
t.Fatalf("Add: %v", err)
|
|
}
|
|
c1, err1 := claimFire(store, now, job.ID)
|
|
c2, err2 := claimFire(store, now, job.ID)
|
|
if err1 != nil || err2 != nil {
|
|
t.Fatalf("claimFire errs: %v %v", err1, err2)
|
|
}
|
|
if !c1 {
|
|
t.Fatal("first claim should win")
|
|
}
|
|
if c2 {
|
|
t.Fatal("second claim must be refused for an unadvanceable job (double-grant)")
|
|
}
|
|
d, _ := store.Get(job.ID)
|
|
if d.Status != cron.StatusPaused {
|
|
t.Fatalf("unadvanceable job must be paused by the winning claim, got %q", d.Status)
|
|
}
|
|
}
|
|
|
|
func TestCronFireDoesNotDoubleFireOnDSTFallBack(t *testing.T) {
|
|
// AUDIT-M4: a daily job in the repeated wall-clock hour must advance past the
|
|
// fall-back repeat, not back to the same-day occurrence one absolute hour later.
|
|
loc, err := time.LoadLocation("America/New_York")
|
|
if err != nil {
|
|
t.Skipf("tzdata unavailable: %v", err)
|
|
}
|
|
store := cron.NewStore(cron.StoreOptions{RootDir: t.TempDir()})
|
|
firstRun := time.Date(2026, 11, 1, 1, 30, 0, 0, loc) // 01:30 EDT, before fall-back
|
|
job, err := store.Add(cron.Job{Expr: "30 1 * * *", Prompt: "x", Status: cron.StatusActive, NextRunAt: firstRun})
|
|
if err != nil {
|
|
t.Fatalf("Add: %v", err)
|
|
}
|
|
// Fire at 01:30:15 EDT with a sub-minute instant, exactly like the real scheduler.
|
|
firedNow := time.Date(2026, 11, 1, 1, 30, 15, 0, loc)
|
|
fx := &fakeExec{}
|
|
var out, errb bytes.Buffer
|
|
cronRun(store, func() time.Time { return firedNow }, []string{"--once"}, &out, &errb, fx.run)
|
|
d, _ := store.Get(job.ID)
|
|
// The fall-back repeat (01:30 EST) is ~1 absolute hour after firedNow; the next
|
|
// real daily slot is ~23h later. A gap > 90m proves we skipped the repeat.
|
|
if gap := d.NextRunAt.Sub(firedNow); gap <= 90*time.Minute {
|
|
t.Fatalf("DST fall-back double-fire: NextRunAt advanced only %s (to the repeated hour), want the next day", gap)
|
|
}
|
|
}
|
|
|
|
func TestCronRunPausesUnadvanceableJob(t *testing.T) {
|
|
now := time.Date(2026, 6, 9, 9, 0, 0, 0, time.UTC)
|
|
store := cron.NewStore(cron.StoreOptions{RootDir: t.TempDir(), Now: func() time.Time { return now }})
|
|
// An impossible schedule stored directly (cronAdd would reject it). After it
|
|
// fires once it cannot advance, so it must be paused, not re-fired forever.
|
|
job, _ := store.Add(cron.Job{Expr: "0 0 30 2 *", Prompt: "x", Status: cron.StatusActive, NextRunAt: now.Add(-time.Minute)})
|
|
fx := &fakeExec{}
|
|
var out, errb bytes.Buffer
|
|
cronRun(store, func() time.Time { return now }, []string{"--once"}, &out, &errb, fx.run)
|
|
d, _ := store.Get(job.ID)
|
|
if d.Status != cron.StatusPaused {
|
|
t.Fatalf("unadvanceable job must be paused, got status=%q", d.Status)
|
|
}
|
|
runs, _ := store.Runs(job.ID)
|
|
if len(runs) != 1 || runs[0].Error == "" {
|
|
t.Fatalf("expected one run record with an error, got %+v", runs)
|
|
}
|
|
}
|
|
|
|
func TestCronRunDashLeadingPromptUsesInlineForm(t *testing.T) {
|
|
now := time.Date(2026, 6, 9, 9, 0, 0, 0, time.UTC)
|
|
store := cron.NewStore(cron.StoreOptions{RootDir: t.TempDir(), Now: func() time.Time { return now }})
|
|
if _, err := store.Add(cron.Job{Expr: "* * * * *", Prompt: "- do a thing", Status: cron.StatusActive, NextRunAt: now.Add(-time.Minute)}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
fx := &fakeExec{}
|
|
var out, errb bytes.Buffer
|
|
cronRun(store, func() time.Time { return now }, []string{"--once"}, &out, &errb, fx.run)
|
|
if len(fx.calls) != 1 || !contains(fx.calls[0], "--prompt=- do a thing") {
|
|
t.Fatalf("dash-leading prompt must use inline --prompt= form: %v", fx.calls)
|
|
}
|
|
}
|
|
|
|
func TestReconcileOverdueKeepsExactlyDueJob(t *testing.T) {
|
|
now := time.Date(2026, 6, 9, 9, 0, 0, 0, time.UTC)
|
|
store := cron.NewStore(cron.StoreOptions{RootDir: t.TempDir(), Now: func() time.Time { return now }})
|
|
exact, _ := store.Add(cron.Job{Expr: "0 9 * * *", Prompt: "exact", Status: cron.StatusActive, NextRunAt: now})
|
|
overdue, _ := store.Add(cron.Job{Expr: "0 9 * * *", Prompt: "old", Status: cron.StatusActive, NextRunAt: now.Add(-24 * time.Hour)})
|
|
var errb bytes.Buffer
|
|
reconcileOverdue(store, func() time.Time { return now }, nil, &errb)
|
|
if e, _ := store.Get(exact.ID); !e.NextRunAt.Equal(now) {
|
|
t.Fatalf("exactly-due job must not be rescheduled, got %v", e.NextRunAt)
|
|
}
|
|
if o, _ := store.Get(overdue.ID); !o.NextRunAt.After(now) {
|
|
t.Fatalf("strictly-overdue job must be pushed to the future, got %v", o.NextRunAt)
|
|
}
|
|
}
|
|
|
|
func TestExtractStreamJSONError(t *testing.T) {
|
|
// Under --output-format stream-json the failure detail rides on stdout as an
|
|
// `error` event; the last one wins, and non-error lines are ignored.
|
|
output := strings.Join([]string{
|
|
`{"type":"run_start","runId":"r1"}`,
|
|
`{"type":"text","delta":"working"}`,
|
|
`{"type":"error","message":"provider request failed: 500"}`,
|
|
`{"type":"error","message":"provider request failed: 502"}`,
|
|
`{"type":"run_end","status":"error","exitCode":1}`,
|
|
}, "\n")
|
|
// With two error events the LAST one wins.
|
|
if got := extractStreamJSONError(output); got != "provider request failed: 502" {
|
|
t.Fatalf("extractStreamJSONError = %q, want the last error event message", got)
|
|
}
|
|
if got := extractStreamJSONError(`{"type":"run_end","status":"success"}`); got != "" {
|
|
t.Fatalf("no error event should yield empty, got %q", got)
|
|
}
|
|
}
|