mirror of
https://github.com/Twigpine/zero.git
synced 2026-11-11 23:40:28 -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]>
314 lines
11 KiB
Go
314 lines
11 KiB
Go
package cli
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/Gitlawb/zero/internal/cron"
|
|
"github.com/Gitlawb/zero/internal/streamjson"
|
|
)
|
|
|
|
// execRunner runs a `zero exec ...` invocation and returns its exit code. The
|
|
// default is cli.Run; tests inject a fake.
|
|
type execRunner func(args []string, stdout, stderr io.Writer) int
|
|
|
|
// cronRun implements `zero cron run [--once] [--catch-up] [id...]`.
|
|
func cronRun(store *cron.Store, now func() time.Time, args []string, stdout io.Writer, stderr io.Writer, exec execRunner) int {
|
|
once, catchUp := false, false
|
|
var ids []string
|
|
for _, a := range args {
|
|
switch {
|
|
case a == "--once":
|
|
once = true
|
|
case a == "--catch-up":
|
|
catchUp = true
|
|
case strings.HasPrefix(a, "-"):
|
|
fmt.Fprintf(stderr, "Unknown cron run flag: %s\n", a)
|
|
return exitUsage
|
|
default:
|
|
ids = append(ids, a)
|
|
}
|
|
}
|
|
|
|
selected := func(j cron.Job) bool {
|
|
if j.Status != cron.StatusActive {
|
|
return false
|
|
}
|
|
if len(ids) == 0 {
|
|
return true
|
|
}
|
|
return contains(ids, j.ID)
|
|
}
|
|
|
|
fireDue := func() {
|
|
jobs, err := store.List()
|
|
if err != nil {
|
|
fmt.Fprintln(stderr, "warning:", err.Error()) // jobs still valid; never fatal
|
|
}
|
|
for _, j := range jobs {
|
|
if !selected(j) || j.NextRunAt.After(now()) {
|
|
continue
|
|
}
|
|
fireJob(store, now, j, stdout, stderr, exec)
|
|
}
|
|
}
|
|
|
|
if once {
|
|
// --once fires every currently-due job once and exits, so --catch-up is a
|
|
// no-op when combined with --once. Intended for use under an external
|
|
// scheduler (system cron / launchd).
|
|
fireDue()
|
|
return exitSuccess
|
|
}
|
|
|
|
// Forever-mode startup: unless --catch-up, push STRICTLY-overdue jobs to their
|
|
// next future slot (skip the backlog) without firing.
|
|
if !catchUp {
|
|
reconcileOverdue(store, now, ids, stderr)
|
|
}
|
|
|
|
ctx, stop := signalContext()
|
|
defer stop()
|
|
fireDue() // fire anything already due before the first tick
|
|
ticker := time.NewTicker(30 * time.Second)
|
|
defer ticker.Stop()
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
fmt.Fprintln(stdout, "cron scheduler stopped.")
|
|
return exitSuccess
|
|
case <-ticker.C:
|
|
fireDue()
|
|
}
|
|
}
|
|
}
|
|
|
|
// contains reports whether ss contains want.
|
|
func contains(ss []string, want string) bool {
|
|
for _, s := range ss {
|
|
if s == want {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// reconcileOverdue (forever-mode startup, non --catch-up) reschedules jobs that
|
|
// are STRICTLY overdue (NextRunAt before the current minute) to their next future
|
|
// slot without firing the backlog. A job due within the current minute is left
|
|
// for the fireDue pass so it still fires now. List errors are warnings, never
|
|
// fatal (jobs remains valid).
|
|
func reconcileOverdue(store *cron.Store, now func() time.Time, ids []string, stderr io.Writer) {
|
|
jobs, err := store.List()
|
|
if err != nil {
|
|
fmt.Fprintln(stderr, "warning:", err.Error())
|
|
}
|
|
nowMin := now().Truncate(time.Minute)
|
|
for _, j := range jobs {
|
|
if j.Status != cron.StatusActive {
|
|
continue
|
|
}
|
|
if len(ids) > 0 && !contains(ids, j.ID) {
|
|
continue
|
|
}
|
|
if !j.NextRunAt.Before(nowMin) {
|
|
continue // not strictly overdue
|
|
}
|
|
if sched, perr := cron.Parse(j.Expr); perr == nil {
|
|
if nxt := sched.Next(now()); !nxt.IsZero() {
|
|
j.NextRunAt = nxt
|
|
if err := store.Update(j); err != nil {
|
|
fmt.Fprintf(stderr, "warning: failed to reschedule %s: %v\n", j.ID, err)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// fireJob runs one job via the exec runner, records the outcome, advances the
|
|
// schedule, and persists. The foreground loop is single-goroutine, so the
|
|
// previous fire has already returned before the next tick — no overlap.
|
|
func fireJob(store *cron.Store, now func() time.Time, job cron.Job, stdout io.Writer, stderr io.Writer, exec execRunner) {
|
|
fired := now()
|
|
// Atomically claim this fire before running it so concurrent schedulers (or
|
|
// --once overlapping the forever loop) can't both execute the same due job: the
|
|
// winner advances NextRunAt under the per-job lock, and a loser sees the
|
|
// advanced time and skips. The post-exec advance recomputes the same
|
|
// NextRunAt (sched.Next(fired) is deterministic), so the claim serializes the
|
|
// fire decision without changing any persisted schedule state (M9).
|
|
claimed, claimErr := claimFire(store, fired, job.ID)
|
|
if claimErr != nil {
|
|
if errors.Is(claimErr, cron.ErrJobNotFound) {
|
|
return // removed before we could claim
|
|
}
|
|
fmt.Fprintf(stderr, "warning: could not claim job %s: %v\n", job.ID, claimErr)
|
|
return
|
|
}
|
|
if !claimed {
|
|
return // another scheduler already claimed this slot, or the job was paused
|
|
}
|
|
|
|
args := []string{"exec", "--output-format", "stream-json", "--session-title", "cron:" + job.ID}
|
|
if job.Cwd != "" {
|
|
args = append(args, "--cwd", job.Cwd)
|
|
}
|
|
if job.Model != "" {
|
|
args = append(args, "--model", job.Model)
|
|
}
|
|
// Inline --prompt= form: a bare "--prompt" "<value>" makes exec reject a
|
|
// dash-leading prompt as a misplaced flag; the =VALUE form is taken verbatim.
|
|
args = append(args, "--prompt="+job.Prompt)
|
|
|
|
var outBuf, errBuf strings.Builder
|
|
code := exec(args, &outBuf, &errBuf)
|
|
|
|
rec := cron.RunRecord{JobID: job.ID, At: fired, ExitCode: code, SessionTitle: "cron:" + job.ID}
|
|
if code != 0 {
|
|
// The job runs with --output-format stream-json, so a failure is reported as
|
|
// an `error` event on STDOUT, not stderr. Prefer that message; fall back to
|
|
// stderr when stdout carries no error event.
|
|
detail := strings.TrimSpace(errBuf.String())
|
|
if streamErr := extractStreamJSONError(outBuf.String()); streamErr != "" {
|
|
detail = streamErr
|
|
}
|
|
rec.Error = cronTruncate(detail, 500)
|
|
}
|
|
|
|
job.FireCount++
|
|
// Advance the schedule. If the expression can no longer produce a future run
|
|
// (became invalid, or is an impossible spec whose Next is zero), pause the job
|
|
// so it cannot re-fire on every tick.
|
|
if sched, perr := cron.Parse(job.Expr); perr != nil {
|
|
job.Status = cron.StatusPaused
|
|
if rec.Error == "" {
|
|
rec.Error = "invalid schedule; job paused: " + perr.Error()
|
|
}
|
|
} else if nxt := sched.Next(fired.Truncate(time.Minute)); nxt.IsZero() {
|
|
job.Status = cron.StatusPaused
|
|
if rec.Error == "" {
|
|
rec.Error = "schedule no longer fires; job paused"
|
|
}
|
|
} else {
|
|
// Minute-aligned input keeps this advance identical to claimFire's and lets
|
|
// the DST fall-back collapse guard engage (AUDIT-M4).
|
|
job.NextRunAt = nxt
|
|
}
|
|
// Re-read and persist the advanced state ATOMICALLY under the per-job lock. The
|
|
// job may have been paused or removed while it executed, and this in-memory copy
|
|
// is stale from tick start. Mutate closes the read-modify-write window a single
|
|
// scheduler alone could only narrow (Store.Mutate took a cross-process lock): a
|
|
// concurrent scheduler or an external pause/remove landing here can no longer be
|
|
// clobbered.
|
|
persisted, err := store.Mutate(job.ID, func(current cron.Job, readErr error) (cron.Job, error) {
|
|
if readErr != nil {
|
|
// A transient read failure (IO/permission) is NOT removal — persist the
|
|
// computed next state anyway so the schedule advances and the job does not
|
|
// re-fire next tick.
|
|
fmt.Fprintf(stderr, "warning: could not re-read job %s before persist: %v\n", job.ID, readErr)
|
|
return job, nil
|
|
}
|
|
if current.Status == cron.StatusPaused {
|
|
// Honor an external pause that landed while the job ran.
|
|
job.Status = cron.StatusPaused
|
|
}
|
|
return job, nil
|
|
})
|
|
if errors.Is(err, cron.ErrJobNotFound) {
|
|
// Genuinely removed mid-run: don't recreate it (no run record, no persist).
|
|
fmt.Fprintf(stdout, "fired %s -> exit %d (job removed during run)\n", job.ID, code)
|
|
return
|
|
}
|
|
// Record the fire. AppendRun takes the per-job lock and bails if the job's
|
|
// metadata is gone, so a Remove landing here cannot resurrect a deleted job's
|
|
// directory with an orphaned runs.jsonl.
|
|
if aerr := store.AppendRun(job.ID, rec); aerr != nil {
|
|
fmt.Fprintf(stderr, "warning: failed to record run for %s: %v\n", job.ID, aerr)
|
|
}
|
|
if err != nil {
|
|
fmt.Fprintf(stderr, "warning: failed to persist job state for %s: %v\n", job.ID, err)
|
|
persisted = job
|
|
}
|
|
fmt.Fprintf(stdout, "fired %s -> exit %d (next: %s)\n", job.ID, code, formatCronTime(persisted.NextRunAt))
|
|
}
|
|
|
|
// claimFire atomically claims a due job's fire under the per-job lock and reports
|
|
// whether THIS caller should run it. It returns false only when the job was paused
|
|
// externally or another scheduler already advanced NextRunAt past `fired` (so a
|
|
// valid recurring job never double-fires). A valid due job has its NextRunAt
|
|
// advanced here to claim the slot; an invalid or exhausted schedule is left for the
|
|
// caller to fire-once and pause (preserving existing single-scheduler behavior).
|
|
func claimFire(store *cron.Store, fired time.Time, id string) (bool, error) {
|
|
claimed := true
|
|
_, err := store.Mutate(id, func(current cron.Job, readErr error) (cron.Job, error) {
|
|
if readErr != nil {
|
|
return current, readErr
|
|
}
|
|
if current.Status != cron.StatusActive {
|
|
claimed = false // paused/other — not ours to fire
|
|
return current, nil
|
|
}
|
|
if current.NextRunAt.After(fired) {
|
|
claimed = false // another scheduler already advanced this slot
|
|
return current, nil
|
|
}
|
|
// Advance to the next slot to claim the fire. Compute it from the minute-
|
|
// aligned fire instant so the schedule's DST fall-back collapse guard (which
|
|
// only engages for a minute-aligned `after`) actually fires, instead of
|
|
// double-firing the repeated wall-clock hour (AUDIT-M4).
|
|
if sched, perr := cron.Parse(current.Expr); perr == nil {
|
|
if nxt := sched.Next(fired.Truncate(time.Minute)); !nxt.IsZero() {
|
|
current.NextRunAt = nxt // advance to claim; concurrent schedulers now skip
|
|
return current, nil
|
|
}
|
|
}
|
|
// Unparseable or unadvanceable schedule (an impossible spec whose Next is
|
|
// zero): pause the job inside this same locked claim so a concurrent scheduler
|
|
// sees a non-active job and does NOT also fire it. The winner still fires once
|
|
// and fireJob's post-exec keeps it paused. Without this, both callers leave
|
|
// NextRunAt unchanged, both see the job due, and both fire the same slot
|
|
// (AUDIT-M5).
|
|
current.Status = cron.StatusPaused
|
|
return current, nil
|
|
})
|
|
return claimed, err
|
|
}
|
|
|
|
func cronTruncate(s string, max int) string {
|
|
if len(s) <= max {
|
|
return s
|
|
}
|
|
// Cut on a UTF-8 rune boundary so a persisted run-error excerpt can't end in
|
|
// a split multi-byte rune (invalid UTF-8 in the cron record).
|
|
return cutRuneBoundary(s, max) + "…"
|
|
}
|
|
|
|
// extractStreamJSONError scans a stream-json output stream for the message of an
|
|
// `error` event (the last one wins). Under --output-format stream-json the
|
|
// failure detail rides on stdout, so this recovers it for the run record.
|
|
func extractStreamJSONError(output string) string {
|
|
found := ""
|
|
for _, line := range strings.Split(output, "\n") {
|
|
line = strings.TrimSpace(line)
|
|
if line == "" {
|
|
continue
|
|
}
|
|
var event struct {
|
|
Type string `json:"type"`
|
|
Message string `json:"message"`
|
|
}
|
|
if err := json.Unmarshal([]byte(line), &event); err != nil {
|
|
continue
|
|
}
|
|
if event.Type == string(streamjson.EventError) {
|
|
if message := strings.TrimSpace(event.Message); message != "" {
|
|
found = message
|
|
}
|
|
}
|
|
}
|
|
return found
|
|
}
|