Files
zero/internal/cron/store.go
euxaristia 6fc1220f6a fix(sandbox): scrub sensitive credentials from sandbox environment (#660)
* fix(windows): retry atomic file renames on Access is denied / Sharing violation

* test(swarm): add unit tests for rename retry and non-retryable errors

* test(swarm): skip TestMailboxRenameRetry on non-Windows platforms

* fix(sandbox): scrub sensitive provider credentials from sandbox env

* refactor(sandbox): unify environment creation and reduce lines of code

* fix(sandbox): forward workspaceRoot through sandboxEnvironment, gate Windows env on backend

* style(sandbox,swarm): remove stray blank lines flagged by gofmt

* refactor(fsutil): extract shared rename retry helper, derive scrub list from provider catalog

Address CodeRabbit review feedback:

- Add internal/fsutil.RenameWithRetry with an injectable rename override and
  move the four duplicated renameWithRetry/isWindowsSharingViolation copies
  (cron, sessions, sandbox, swarm) onto it. Mailbox keeps its rename test
  seam by passing its field through. Add fsutil tests for the Windows retry
  and non-retryable paths.
- Remove the orphaned metadata.json.tmp in cron writeJob when the rename
  permanently fails, matching the sibling writers.
- Derive scrubSensitiveEnv keys from providercatalog AuthEnvVars plus a
  static list of non-catalog secrets, so new providers are scrubbed
  automatically. AWS_PROFILE and GOOGLE_APPLICATION_CREDENTIALS stay
  because they are pointers, not secrets.

* fix(sandbox): deny sandboxed reads of cloud credential stores by default

Address CodeRabbit finding that scrubbed env pointers still left the
underlying credential files readable under the read-all workspace posture:

- Add credentialDenyReadPaths: default profile-level deny-read entries for
  ~/.aws, ~/.config/gcloud, ~/.azure, and the GOOGLE_APPLICATION_CREDENTIALS
  target file, wired into both permission profile builders so the seatbelt
  and Linux helper backends enforce them. Entries are stat-filtered and a
  store nested under a user AllowRead entry is dropped, keeping an explicit
  opt-out.
- Keep the defaults out of Policy.DenyRead, whose emptiness gates escalated
  execution, and skip Windows so the profile deny list does not flip the
  runner off the WRITE_RESTRICTED token path.
- Scrub GOOGLE_APPLICATION_CREDENTIALS from the sandbox env; keep AWS_PROFILE
  since the deny-read rule on ~/.aws is the real protection and the SDKs
  fall back to the default profile regardless.
- Add credential deny path tests, extend the scrub test, relax the profile
  test's exact deny count to containment.

* test(cli): pin HOME in sandbox policy golden test

The default credential-store deny-read entries depend on what exists in the
real home directory (the macOS CI image ships ~/.aws), which leaked host
paths into the policy JSON golden comparison. Point HOME at an empty temp
directory so the golden stays hermetic.

* fix(sandbox): protect GOOGLE_APPLICATION_CREDENTIALS without a home, retry lock violations

Address CodeRabbit review feedback:

- credentialDenyReadPaths no longer returns early when the home lookup
  fails; home-based candidates become conditional and the
  GOOGLE_APPLICATION_CREDENTIALS target is always considered.
- fsutil.RenameWithRetry also retries ERROR_LOCK_VIOLATION (errno 33),
  matching the mcp lock handling; helper renamed to
  isWindowsSharingOrLockViolation.
- Extend tests: homeless GOOGLE_APPLICATION_CREDENTIALS case and a lock
  violation step in the retry sequence.

* fix(sandbox): tighten env-scrub test assertion and add missing secret keys

TestScrubSensitiveEnv only checked an allowlist plus a length count, so a
duplicate allowed key silently replacing a required one would still pass.
Compare the exact expected key/value map instead.

Also add ZERO_WEBSEARCH_API_KEY and ZERO_DAEMON_REMOTE_TOKEN to
scrubSensitiveEnv's sensitiveKeys list: both are real bearer secrets read
from the environment but were missing from the scrub list, leaving them
readable by sandboxed commands when set in the parent shell.
2026-07-14 21:46:10 +08:00

331 lines
9.0 KiB
Go

package cron
import (
"bufio"
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"time"
"github.com/Gitlawb/zero/internal/fsutil"
)
const (
StatusActive = "active"
StatusPaused = "paused"
)
// ErrJobNotFound is returned (wrapped) by Get when a job's metadata file is
// absent — a genuinely removed job. Callers use errors.Is to distinguish this
// from a transient read failure (IO error, permission), which must NOT be
// treated as "job removed".
var ErrJobNotFound = errors.New("cron job not found")
// Job is a stored scheduled job.
type Job struct {
ID string `json:"id"`
Expr string `json:"expr"`
Prompt string `json:"prompt"`
Cwd string `json:"cwd,omitempty"`
Model string `json:"model,omitempty"`
Status string `json:"status"`
FireCount int `json:"fireCount"`
NextRunAt time.Time `json:"nextRunAt"`
CreatedAt time.Time `json:"createdAt"`
}
// RunRecord is one fire's outcome, appended to the job's runs.jsonl.
type RunRecord struct {
JobID string `json:"jobId"`
At time.Time `json:"at"`
ExitCode int `json:"exitCode"`
SessionTitle string `json:"sessionTitle,omitempty"`
Error string `json:"error,omitempty"`
}
// StoreOptions configures a Store. RootDir defaults to DefaultRoot(os env); Now
// defaults to time.Now. Both injectable for tests.
type StoreOptions struct {
RootDir string
Now func() time.Time
}
type Store struct {
root string
now func() time.Time
}
func NewStore(opts StoreOptions) *Store {
root := opts.RootDir
if root == "" {
root = DefaultRoot(envMap())
}
now := opts.Now
if now == nil {
now = time.Now
}
return &Store{root: root, now: now}
}
// DefaultRoot mirrors sessions.DefaultRoot: <XDG_DATA_HOME|~/.local/share>/zero/cron.
func DefaultRoot(env map[string]string) string {
dataHome := strings.TrimSpace(env["XDG_DATA_HOME"])
home := strings.TrimSpace(env["HOME"])
if home == "" {
// Mirror sessions.DefaultRoot: fall back to the OS user home so an unset
// HOME (Windows, restricted shells) doesn't yield a RELATIVE ".local/share"
// under the caller's cwd, which would scatter cron data per working dir.
if userHome, err := os.UserHomeDir(); err == nil {
home = userHome
}
}
base := dataHome
if base == "" {
base = filepath.Join(home, ".local", "share")
}
return filepath.Join(base, "zero", "cron")
}
func envMap() map[string]string {
return map[string]string{"XDG_DATA_HOME": os.Getenv("XDG_DATA_HOME"), "HOME": os.Getenv("HOME")}
}
// Add assigns an ID + CreatedAt and writes the job's metadata.json.
func (s *Store) Add(job Job) (Job, error) {
if job.Status == "" {
job.Status = StatusActive
}
job.CreatedAt = s.now().UTC()
id, err := s.allocID()
if err != nil {
return Job{}, err
}
job.ID = id
if err := s.writeJob(job); err != nil {
return Job{}, err
}
return job, nil
}
func (s *Store) allocID() (string, error) {
base := s.now().UTC().Format("20060102-150405")
for n := 0; n < 100; n++ {
id := base
if n > 0 {
id = fmt.Sprintf("%s-%d", base, n)
}
if _, err := os.Stat(filepath.Join(s.root, id)); errors.Is(err, os.ErrNotExist) {
return id, nil
}
}
return "", errors.New("could not allocate a unique cron job id")
}
func (s *Store) jobDir(id string) string { return filepath.Join(s.root, id) }
// validID rejects ids that could escape the store root (path separators or
// traversal). allocID-generated timestamp ids always pass; this guards
// externally-supplied ids (get/update/remove/append).
func validID(id string) bool {
return id != "" && id != "." && id != ".." && !strings.ContainsAny(id, `/\`) && filepath.Base(id) == id
}
func (s *Store) writeJob(job Job) error {
dir := s.jobDir(job.ID)
if err := os.MkdirAll(dir, 0o700); err != nil {
return err
}
data, err := json.MarshalIndent(job, "", " ")
if err != nil {
return err
}
tmp := filepath.Join(dir, "metadata.json.tmp")
if err := os.WriteFile(tmp, data, 0o600); err != nil {
return err
}
if err := fsutil.RenameWithRetry(tmp, filepath.Join(dir, "metadata.json"), nil); err != nil {
_ = os.Remove(tmp)
return err
}
return nil
}
func (s *Store) Get(id string) (Job, error) {
if !validID(id) {
return Job{}, fmt.Errorf("invalid cron job id %q", id)
}
data, err := os.ReadFile(filepath.Join(s.jobDir(id), "metadata.json"))
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return Job{}, fmt.Errorf("cron job %q: %w", id, ErrJobNotFound)
}
// A transient read failure (IO error, permission) is NOT a missing job;
// surface it as a distinct error so callers don't mistake it for removal.
return Job{}, fmt.Errorf("read cron job %q: %w", id, err)
}
var job Job
if err := json.Unmarshal(data, &job); err != nil {
return Job{}, fmt.Errorf("cron job %q is corrupt: %w", id, err)
}
return job, nil
}
func (s *Store) Update(job Job) error {
if !validID(job.ID) {
return fmt.Errorf("invalid cron job id %q", job.ID)
}
unlock, err := s.lockJob(job.ID)
if err != nil {
return err
}
defer unlock()
if _, err := os.Stat(s.jobDir(job.ID)); err != nil {
return fmt.Errorf("cron job %q not found", job.ID)
}
return s.writeJob(job)
}
// Mutate atomically updates job id under the per-job cross-process lock, closing
// the read-modify-write race between concurrent schedulers (and against Update/
// Remove). It re-reads the CURRENT on-disk job and passes it to mutate along with
// any read error; mutate returns the job to persist. A removed job aborts with
// ErrJobNotFound (no recreate). A transient read error (not removal) is surfaced
// via readErr so the caller can still persist a best-effort state — the fire path
// advances the schedule regardless, to avoid a re-fire.
func (s *Store) Mutate(id string, mutate func(current Job, readErr error) (Job, error)) (Job, error) {
if !validID(id) {
return Job{}, fmt.Errorf("invalid cron job id %q", id)
}
unlock, err := s.lockJob(id)
if err != nil {
return Job{}, err
}
defer unlock()
current, readErr := s.Get(id)
if errors.Is(readErr, ErrJobNotFound) {
return Job{}, ErrJobNotFound
}
next, err := mutate(current, readErr)
if err != nil {
return Job{}, err
}
if err := s.writeJob(next); err != nil {
return Job{}, err
}
return next, nil
}
func (s *Store) Remove(id string) error {
if !validID(id) {
return fmt.Errorf("invalid cron job id %q", id)
}
unlock, err := s.lockJob(id)
if err != nil {
return err
}
defer unlock()
if _, err := os.Stat(s.jobDir(id)); err != nil {
return fmt.Errorf("cron job %q not found", id)
}
return os.RemoveAll(s.jobDir(id))
}
func (s *Store) List() ([]Job, error) {
entries, err := os.ReadDir(s.root)
if errors.Is(err, os.ErrNotExist) {
return nil, nil
}
if err != nil {
return nil, err
}
var jobs []Job
var corrupt []string
for _, e := range entries {
if !e.IsDir() {
continue
}
if _, statErr := os.Stat(filepath.Join(s.jobDir(e.Name()), "metadata.json")); statErr != nil {
continue // a directory without metadata.json is not a job
}
job, gerr := s.Get(e.Name())
if gerr != nil {
corrupt = append(corrupt, e.Name())
continue
}
jobs = append(jobs, job)
}
// Surface corrupt jobs (jobs slice is still authoritative; callers treat this
// as a warning, not a fatal error, so one bad job never hides the rest).
if len(corrupt) > 0 {
return jobs, fmt.Errorf("skipped %d unreadable cron job(s): %s", len(corrupt), strings.Join(corrupt, ", "))
}
return jobs, nil
}
func (s *Store) AppendRun(id string, rec RunRecord) error {
if !validID(id) {
return fmt.Errorf("invalid cron job id %q", id)
}
unlock, err := s.lockJob(id)
if err != nil {
return err
}
defer unlock()
// Bail if the job was removed (e.g. mid-run) — otherwise the MkdirAll below
// would resurrect a deleted job's directory with an orphaned runs.jsonl and no
// metadata.json (which Runs would then still return). Under the per-job lock
// this stat-then-write is race-free against a concurrent Remove.
if _, err := os.Stat(filepath.Join(s.jobDir(id), "metadata.json")); err != nil {
if errors.Is(err, os.ErrNotExist) {
return nil
}
return err
}
dir := s.jobDir(id)
if err := os.MkdirAll(dir, 0o700); err != nil {
return err
}
f, err := os.OpenFile(filepath.Join(dir, "runs.jsonl"), os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o600)
if err != nil {
return err
}
line, err := json.Marshal(rec)
if err != nil {
_ = f.Close()
return err
}
if _, err := f.Write(append(line, '\n')); err != nil {
_ = f.Close()
return err
}
// Surface a buffered-write failure that only materializes on Close.
return f.Close()
}
func (s *Store) Runs(id string) ([]RunRecord, error) {
if !validID(id) {
return nil, fmt.Errorf("invalid cron job id %q", id)
}
f, err := os.Open(filepath.Join(s.jobDir(id), "runs.jsonl"))
if errors.Is(err, os.ErrNotExist) {
return nil, nil
}
if err != nil {
return nil, err
}
defer f.Close()
var runs []RunRecord
scanner := bufio.NewScanner(f)
scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024)
for scanner.Scan() {
var rec RunRecord
if json.Unmarshal(scanner.Bytes(), &rec) == nil {
runs = append(runs, rec)
}
}
return runs, scanner.Err()
}