fix(secrets,worktrees): fix secret redaction leakage and prune stale worktrees#632
fix(secrets,worktrees): fix secret redaction leakage and prune stale worktrees#632euxaristia wants to merge 12 commits into
Conversation
|
Note Reviews pausedIt looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the Use the following commands to manage reviews:
Use the checkboxes below for quick actions:
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: CHILL Plan: Pro Run ID: 📒 Files selected for processing (8)
🚧 Files skipped from review as they are similar to previous changes (7)
WalkthroughSecret scanner patterns now support variable-length tokens and selective modern or legacy OpenAI key matching, with expanded redaction boundary tests. Worktree preparation now cleans stale owned worktrees, manages locks, and exposes CLI release support. ChangesSecret scanner matching
Worktree lifecycle
Estimated code review effort: 4 (Complex) | ~60 minutes Sequence Diagram(s)sequenceDiagram
participant Exec
participant Prepare
participant Clean
participant Git
participant Release
Exec->>Prepare: prepare worktree
Prepare->>Clean: Clean(ctx, options, 24h)
Clean->>Git: inspect and prune stale worktrees
Prepare->>Git: create or reuse and lock worktree
Exec->>Release: release acquired lock on exit
Release->>Git: git worktree unlock
Possibly related PRs
Suggested reviewers: 🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
3679cf6 to
fd1e893
Compare
Vasanthdev2004
left a comment
There was a problem hiding this comment.
Reviewed #632 from a security-redaction and worktree-hygiene angle. Build, go vet ./..., gofmt, and go test ./internal/secrets/... ./internal/worktrees/... -count=1 are all clean; the new tests for both areas pass.
Secret redaction (internal/secrets/scanner.go)
The leak is real and this closes it for the main vectors. The original fixed-length patterns ({36}, {35}, {16}) matched only a prefix of a longer/appended key, leaving the tail un-redacted; switching to variable-length {N,} makes the greedy match consume the whole run. I traced the un-redacted tail path through Redact -> strings.ReplaceAll and confirmed the longer-key test cases (ghp_... 50 chars, AIza... 46 chars) now fully redact. The openai_key refactor is a nice precision win: the old \bsk-[A-Za-z0-9_-]{20,} matched sk-learn-machine-learning-model as a secret (I confirmed the new kebab-case test fails on the old pattern); the explicit sk-proj-/sk-svcacct- alternation plus an alnum-only legacy branch fixes that without losing real legacy or modern keys. Existing high-confidence tests still pass, so no over-redaction regression on the canonical shapes.
One minor edge case worth a look (non-blocking): the trailing \b is added to every pattern, but several body classes include -, which is a non-word character. For google_api_key, slack_token, the openai modern branch, and jwt, a secret that ends in - followed by a space backtracks and leaves the trailing - un-redacted (I verified AIzaSyA...v- -> [REDACTED]-). It is only a low-entropy char and real secrets rarely end in -, so it does not block, but it is the same tail-leak class this PR is fixing. The variable-length {N,} alone already closes the long-key leak, so the trailing \b could be dropped on the --allowing patterns without reopening it.
Worktree prune (internal/worktrees/worktrees.go)
Clean is scoped correctly in the happy path: it only removes worktrees whose path is under zero's baseDir (user/external worktrees are skipped), it uses a conservative 24h threshold, and it is auto-invoked only in production (RunGit == nil), so tests with a fake runner are unaffected. The new TestCleanPrunesStaleWorktrees verifies the call sequence.
My main concern is that "stale" is decided solely by the worktree directory's mtime, and removal uses --force. Zero does not git worktree lock its worktrees (Prepare only runs git worktree add --detach), and there is no active-task registry consulted before removal. The directory mtime is set at creation and is not refreshed when a task merely edits existing files deeper in the tree, so a long-running task (e.g. a swarm/background agent running >24h) can look stale. If another task then starts and calls Prepare, Clean will run git worktree remove --force on the active worktree, discarding any uncommitted work and leaving the running task pointing at a deleted directory. For the typical short-lived interactive worktree this is fine, and I agree the orphan-accumulation problem is real and worth solving, but the 24h-mtime + --force combination is a data-loss risk for long runs. I'd suggest at least skipping --force on the first pass (so dirty worktrees are preserved) or consulting an active-task marker before removing.
Two smaller notes:
- The baseDir ownership check is a raw
strings.HasPrefix(path, baseDir); a sibling directory like<baseDir>-otherwould satisfy it. Zero never creates worktrees there so it is practically safe, but a path-boundary check (baseDir + separator) would be more robust and avoids a Windows case-sensitivity quirk in the compare. - The PR bundles two unrelated fixes (secret scanner regex + worktree cleanup) in different packages with no code dependency. Both are small and legitimate, so I would not block on splitting, but if you prefer focused PRs these could have been two.
Verdict
Comment. The redaction fix is correct and worth merging on its own; I'd like the worktree prune's --force + mtime-only + no-active-check interaction addressed (or at least acknowledged) before I'm comfortable relying on it for long-running --worktree runs. I'm not blocking the security fix — flagging this so it isn't lost.
anandh8x
left a comment
There was a problem hiding this comment.
Thanks for these. The scanner fix is solid and ready on its own — variable-length + {N,} correctly closes the long-key tail leak, and the openai_key split is a clean precision win. I want to merge that part.
The worktree Clean is a real fix for a real leak, but the current shape is a data-loss footgun: mtime-only is not a safe stale signal, and --force will happily remove an active worktree when a long-running task (>24h) is still in it. Two concrete things I'd want addressed before I rely on this:
-
Bound the data-loss risk on long runs. The cleanest option is for
Preparetogit worktree lockeach worktree it creates, and haveCleanskip locked worktrees. A lighter alternative is totouchthe worktree dir on each subsequent in-tree operation (so liveness tracks actual use, not creation). Either is fine; please don't ship the mtime-only +--forcecombination. -
baseDirownership check is a rawstrings.HasPrefix(path, baseDir). Use a path-boundary check (baseDir + separator) so<baseDir>-otherdoesn't match, and the comparison is filesystem-correct on Windows.
Two non-blockers I'd also accept in this PR or as follow-ups:
- The trailing
\bon the-allowing patterns (google_api_key, slack_token, modern openai_key, jwt) leaves a trailing-un-redacted — same tail-leak class the PR is fixing, just with a low-entropy char. Drop\b` on those patterns only. - The PR bundles two unrelated fixes. If the worktree fix grows, splitting is fine; otherwise one PR is OK.
CI is green; once the worktree liveness signal is in and the path-boundary check is in, this is a fast approve.
gnanam1990
left a comment
There was a problem hiding this comment.
Verdict: Request changes
Two legitimate fixes bundled in one PR. The secrets scanner changes look ready. The worktree Clean path has real data-loss risk that @anandh8x already flagged in a formal CHANGES_REQUESTED review. Merges cleanly on main after rebase (6081cf0). Gate: @anandh8x requested changes; @Vasanthdev2004 commented (not approved).
What looks good
- Secret redaction fix is correct. Variable-length
{N,}plus trailing\bon fixed-alphabet patterns closes the long-key tail leak forgithub_token,aws_access_key_id, etc. New tests (TestScanRedactsLongerKeysWithoutTailLeak) cover the failure mode. openai_keyprecision win. Splittingsk-proj-/sk-svcacct-from legacysk-[A-Za-z0-9]{20,}stops false positives likesk-learn-machine-learning-modelwithout losing real keys.- Worktree leak is real. Orphaned worktrees under
~/.local/state/zero/worktreesaccumulating is a valid hygiene problem;Cleanis scoped to zero-owned paths underbaseDirand only auto-runs in production (RunGit == nil). - Verification:
make lintclean;go test -race -count=1 ./internal/secrets/... ./internal/worktrees/...passes.
Nits
- Bundled unrelated fixes (scanner + worktrees) — fine at this size, but splittable if the worktree side grows.
- Trailing
\bon hyphen-allowing patterns (google_api_key,slack_token, modernopenai_key,jwt) can leave a trailing-un-redacted when the secret ends in-before a space. Low-entropy tail; same class as the bug being fixed. Drop\bon those patterns only (per @anandh8x / @Vasanthdev2004). _ = Clean(...)inPreparesilently swallows cleanup failures — acceptable for best-effort prune, but worth a debug log if cleanup starts mattering.
Issues
1. Clean can delete active long-running worktrees — blocking
Stale detection uses only directory mtime, and removal uses git worktree remove --force. Prepare does not git worktree lock, and there is no liveness marker. A task running >24h that doesn't touch the worktree root looks stale; the next Prepare can force-remove it mid-run, discarding uncommitted work.
Fix options (either is fine per @anandh8x):
git worktree lockon create;Cleanskips locked worktrees, or- Touch worktree dir on reuse / active use so mtime tracks liveness.
Do not ship mtime-only + --force without one of these.
2. baseDir ownership check is a raw prefix match — blocking
A sibling like <baseDir>-other/... would match. Use a path-boundary check (baseDir + string(os.PathSeparator) or filepath.Rel) so the comparison is filesystem-correct on Windows too.
Recommendation: Merge the scanner half as-is (or split the PR). Address worktree liveness + path boundary before relying on auto-prune in production.
There was a problem hiding this comment.
Actionable comments posted: 2
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@internal/worktrees/worktrees.go`:
- Around line 357-361: The worktree cleanup flow around os.Stat and runGit
currently treats nonzero Git exit codes as success because defaultRunGit can
return nil errors with a failed CommandResult. Route prune and removal
operations through gitOutput or explicitly validate CommandResult.ExitCode
before reporting success, and add a test covering nonzero Git exits.
- Around line 431-448: Update worktreeIsStale to fail closed when
filepath.WalkDir or DirEntry.Info encounters an error: track inspection failure
and return false rather than treating the worktree as stale. Preserve the
existing stale=false and filepath.SkipAll behavior when a modification time is
newer than cutoff, while ensuring any incomplete inspection cannot authorize
forced removal.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Pro
Run ID: 509b95aa-0ff5-4f58-8334-6f1ddeb083f4
📒 Files selected for processing (4)
internal/secrets/scanner.gointernal/secrets/scanner_test.gointernal/worktrees/worktrees.gointernal/worktrees/worktrees_test.go
|
Addressed both reviews. Dropped the trailing \b on the four secret patterns whose body class allows a hyphen (slack_token, google_api_key, the modern openai_key branch, jwt), which could leave a trailing hyphen un-redacted right before a delimiter. For the worktree Clean data-loss risk: staleness is no longer decided by the top-level directory's own mtime (which does not update when a task edits existing files deeper in the tree), Clean now walks the tree and treats any recently modified entry as live, and also respects an explicit git worktree lock. Replaced the baseDir ownership check's raw string prefix match with a filepath.Rel path-boundary check so a sibling directory can't false-match. Also fixed two issues CodeRabbit found in that same fix: the removal call now checks the git exit code instead of trusting a nil error, and an inspection failure during the staleness walk now fails closed instead of open. |
jatmn
left a comment
There was a problem hiding this comment.
I found issues that need to be addressed before this is ready.
Findings
-
[P1] Keep detecting fixed-format keys with appended word characters
internal/secrets/scanner.go:42
The new trailing word boundaries make a credential disappear from the scanner when a character outside its body class is nevertheless a word character. For example,AKIAIOSFODNN7EXAMPLEEXTRAhas no boundary after the 16th AWS body character, and aghp_key followed by_suffixlikewise has no boundary after its alphanumeric body;Scanreturns no finding and the full credential reaches the redaction/model-context path. This is the appended-key case the PR is meant to harden. Preserve redaction of the credential prefix (or otherwise consume the appended run safely) and add these delimiter cases to the regression tests. -
[P1] Do not drop OpenAI admin keys from redaction
internal/secrets/scanner.go:49
The narrowed modern-key alternative accepts onlysk-proj-andsk-svcacct-; the legacy alternative then requires the character immediately aftersk-to be alphanumeric. As a result, ansk-admin-...key matches neither branch and is emitted unchanged byRedact, whereas the previous hyphen-tolerant matcher redacted it. Keep the false-positive protection for ordinary kebab-case text while recognizing the other valid OpenAI key families, and cover them with a test. -
[P1] Restrict automatic pruning to worktrees Zero actually created
internal/worktrees/worktrees.go:350
Preparecreates worktrees below<BaseDir>/zero-worktree-<repoKey>/, butCleanauthorizes deletion of every worktree merely located anywhere below the caller-providedBaseDir. A user can point--worktree-dirat a shared directory that already has a manually managed same-repository worktree; after 24 hours of old mtimes, the next Zero prepare runsgit worktree remove --forceon that unrelated checkout and discards its uncommitted changes. Require the per-repository Zero-owned subtree (or a durable ownership marker) before pruning. -
[P1] Do not force-remove worktrees based only on filesystem mtimes
internal/worktrees/worktrees.go:368
The new recursive scan still does not establish that an unlocked worktree is abandoned. A Zero task can hold uncommitted work while waiting on a model, user, network, or other process for more than 24 hours without writing a file; anotherPreparethen classifies it stale and force-removes it. The same race exists if activity begins afterWalkDirfinishes.Preparenever locks or leases the worktrees it creates, so the locked-entry exemption does not protect the normal path. Add a durable active-worktree mechanism and/or refuse forced removal of dirty worktrees; cover active-but-idle and dirty-old cases.
|
Pushed d5146e2 for all four P1s. Appended-suffix keys: the fixed/unbounded-greedy quantifiers on aws_access_key_id, github_token, and github_pat required a trailing word boundary right after the credential body. When a word character outside the body class follows immediately (AKIA...EXAMPLEEXTRA, ghp_..._suffix), there is no word/non-word transition at that point, so the whole match failed and the credential passed through unredacted. Removed the trailing \b anchors on all three; the character class itself already stops the match where it should, and the leading \b still keeps them from firing mid-word. This matches how slack_token, google_api_key, and the modern openai_key branch already worked. Added a regression test covering both patterns with appended suffixes. OpenAI admin keys: added sk-admin- alongside sk-proj-/sk-svcacct- to the prefixed alternative so it isn't caught by the narrowed legacy branch (which requires an alphanumeric character immediately after sk-, and admin- has a hyphen there). Added it to the existing modern-prefixed-key test. Worktree pruning scope: Clean now computes the same zero-worktree- subtree Prepare creates worktrees under, and only prunes entries inside that subtree rather than anywhere under the caller-supplied BaseDir. A worktree a user manages by hand elsewhere under a shared BaseDir is never touched. Added a test with a hand-managed worktree directly under BaseDir to confirm it's skipped, and adjusted the existing sibling-prefix test to exercise the boundary at the repoDir level. Dirty worktrees: added a worktreeIsDirty check (git status --porcelain in the worktree) before force-removing anything the mtime walk flagged stale. If it has uncommitted or untracked changes, Clean skips it instead of removing it, since a task can hold live work in a worktree while waiting on a model, network, or user for longer than the staleness window without touching the tree again. The check fails closed (treats an inspection error as dirty) so a broken git call can't authorize a removal. This covers the concrete data-loss scenario in the finding; a durable lease/lock mechanism for the harder "active but never wrote anything" case would be a larger follow-up if you want it. |
There was a problem hiding this comment.
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
internal/worktrees/worktrees_test.go (1)
387-434: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win
TestCleanSkipsWorktreeWithRecentNestedActivitydoesn't actually test deeply nested file detection.The intermediate directory
activePath/internalis created byMkdirAllbut never backdated, so its mtime ≈ now.filepath.WalkDirvisitsactivePath/internalbefore reachinghandler.go, finds it recent, and returnsnot-staleimmediately — never exercising the nested file's mtime. This means the test would still pass even ifworktreeIsStaleonly checked direct children instead of recursively walking the tree.Backdate all intermediate directories and explicitly set only the file's mtime to now so the walk must reach
handler.goto determine the worktree is not stale.🛠️ Proposed fix for test fidelity
if err := os.Chtimes(nestedDir, twoDaysAgo, twoDaysAgo); err != nil { t.Fatal(err) } // The file itself is freshly written (default mtime is now), simulating a // task actively editing code deep in the worktree. nestedFile := filepath.Join(nestedDir, "handler.go") if err := os.WriteFile(nestedFile, []byte("package pkg"), 0o644); err != nil { t.Fatal(err) } + + // Backdate all directories so that only the nested file has a recent + // mtime. Without this, activePath/internal retains its creation-time + // mtime and WalkDir finds it recent before reaching the file. + if err := os.Chtimes(filepath.Join(activePath, "internal"), twoDaysAgo, twoDaysAgo); err != nil { + t.Fatal(err) + } + if err := os.Chtimes(nestedDir, twoDaysAgo, twoDaysAgo); err != nil { + t.Fatal(err) + } + now := time.Now() + if err := os.Chtimes(nestedFile, now, now); err != nil { + t.Fatal(err) + }🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@internal/worktrees/worktrees_test.go` around lines 387 - 434, Update TestCleanSkipsWorktreeWithRecentNestedActivity to backdate every intermediate directory created under activePath, including activePath/internal, before writing the file. Ensure only nestedFile retains its current mtime, so the recursive worktreeIsStale traversal must reach handler.go to identify recent activity.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Outside diff comments:
In `@internal/worktrees/worktrees_test.go`:
- Around line 387-434: Update TestCleanSkipsWorktreeWithRecentNestedActivity to
backdate every intermediate directory created under activePath, including
activePath/internal, before writing the file. Ensure only nestedFile retains its
current mtime, so the recursive worktreeIsStale traversal must reach handler.go
to identify recent activity.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Pro
Run ID: 094782d0-0556-42c0-b22f-07f7995446e5
📒 Files selected for processing (4)
internal/secrets/scanner.gointernal/secrets/scanner_test.gointernal/worktrees/worktrees.gointernal/worktrees/worktrees_test.go
🚧 Files skipped from review as they are similar to previous changes (1)
- internal/worktrees/worktrees.go
|
Pushed 310f4a6 for the CodeRabbit test-fidelity note on TestCleanSkipsWorktreeWithRecentNestedActivity. activePath/internal was created by the same MkdirAll as the nested pkg directory but was never backdated, so it kept a fresh mtime and the staleness walk reported not-stale as soon as it reached that directory, never actually exercising the recursive check past the first level. Backdated it too so the walk has to pass through internal and pkg (both old) and reach handler.go (fresh) to reach the right answer. |
jatmn
left a comment
There was a problem hiding this comment.
I found issues that need to be addressed before this is ready.
Findings
-
[P1] Do not force-delete a clean worktree without an active-owner lease
internal/worktrees/worktrees.go:47-51,374-390
Preparenow runs cleanup before every production worktree creation, while the worktrees it creates are detached and never locked or leased. A live task can be clean and untouched for more than 24 hours while it waits on a user, model, network, or external process (or after it has committed intermediate work). A laterPrepareclassifies that worktree as stale and runsgit worktree remove --force, deleting the active checkout and potentially stranding its unpushed detached commit. The dirty and recent-mtime checks do not establish liveness. Keep a durable active ownership marker/lock for the lifetime of a task, or do not automatically force-remove a worktree unless there is a safe completion signal. -
[P1] Treat ignored task data as live before invoking forced removal
internal/worktrees/worktrees.go:374-390
The sole content check isgit status --porcelain, which intentionally omits ignored files, and the subsequentgit worktree remove --forceremoves those files. I reproduced this with a tracked.gitignore: anignored-datafile yields empty normal porcelain output, but--ignoredreports!! ignored-data; the forced removal then deletes the worktree. That can discard ignored local credentials, generated drafts, or task artifacts even though the code says untracked work is protected. Include ignored content in the liveness guard, or avoid forced automatic removal of worktrees containing user/task data.
|
Pushed 4b2f232 for the two remaining worktree-cleanup P1s:
Added 3 new tests plus updated 2 existing ones for the changed status command and lock call. One deliberate deviation: didn't add a |
jatmn
left a comment
There was a problem hiding this comment.
I found issues that need to be addressed before this is ready.
Findings
- [P1] Release Zero-owned worktree locks when the task is finished
internal/worktrees/worktrees.go:137
This adds a permanentgit worktree lockto every newly created worktree, butCleanskips every locked entry and there is nogit worktree unlockor other release path anywhere in the production lifecycle. Consequently, once a normalzero exec --worktreeorzero worktrees preparetask finishes, that checkout remains ineligible for the new 24-hour cleanup forever. The lock prevents the data-loss case from the earlier review, but it also makes the advertised disk-space cleanup inert for every worktree Zero creates. Please add a lifecycle-bound lease/unlock mechanism (or use a distinct active-task marker) so completed worktrees can become safe cleanup candidates.
|
Pushed a fix for the lock-never-released issue. Prepare locks every worktree it creates so Clean's mtime+dirty heuristic never force-removes one Zero is still using, but nothing unlocked it anywhere, so Clean was permanently inert for every Zero-created worktree. Added
Also confirmed the two scanner findings from the earlier review (appended-suffix key leaks, dropped
|
There was a problem hiding this comment.
Actionable comments posted: 1
🧹 Nitpick comments (2)
internal/worktrees/worktrees.go (2)
425-428: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low valueAggregate removal errors.
If multiple stale worktrees fail to be removed (e.g., due to file-locking or permissions),
lastErris continuously overwritten and only the final error is returned. Consider usingerrors.Jointo aggregate and surface all removal failures.🛠️ Proposed refactor
- if _, err := gitOutput(ctx, runGit, repoRoot, "worktree", "remove", "--force", entry.path); err != nil { - lastErr = fmt.Errorf("remove worktree %s: %w", entry.path, err) - } + if _, err := gitOutput(ctx, runGit, repoRoot, "worktree", "remove", "--force", entry.path); err != nil { + lastErr = errors.Join(lastErr, fmt.Errorf("remove worktree %s: %w", entry.path, err)) + }🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@internal/worktrees/worktrees.go` around lines 425 - 428, Update the stale worktree removal error handling around gitOutput so failures are accumulated with errors.Join instead of overwriting lastErr. Preserve any existing error and include each new “remove worktree” error, ensuring the final result reports all removal failures.
159-168: 🎯 Functional Correctness | 🔵 Trivial | 💤 Low valueSupport unlocking worktrees with missing directories.
If a user manually deletes a locked worktree directory (
rm -rf <path>),Releasewill fail to unlock it because the execution layer cannotchdirinto the non-existentpathto rungit.
Consider falling back tooptions.Cwdifpathdoes not exist. This allows users to still unlock and prune the leaked worktree by runningzero worktrees release <path>from the main repository.🛠️ Proposed fallback
func Release(ctx context.Context, options Options, path string) error { runGit := options.RunGit if runGit == nil { runGit = defaultRunGit } + + dir := path + if _, err := os.Stat(path); os.IsNotExist(err) { + if cwd, err := resolveCwd(options.Cwd); err == nil { + dir = cwd + } + } - if _, err := gitOutput(ctx, runGit, path, "worktree", "unlock", path); err != nil { + if _, err := gitOutput(ctx, runGit, dir, "worktree", "unlock", path); err != nil { return fmt.Errorf("unlock git worktree: %w", err) } return nil }🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@internal/worktrees/worktrees.go` around lines 159 - 168, Update Release to choose a valid working directory before calling gitOutput: use path when it exists, but fall back to options.Cwd when the locked worktree directory is missing. Preserve the existing worktree unlock arguments and error handling so release can unlock deleted worktrees from the main repository.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@internal/cli/workflows.go`:
- Around line 136-138: Normalize path to an absolute path before invoking
deps.releaseWorktree in the release workflow. Update the path variable used by
the call so both the git working directory and unlock target receive the
normalized value, while preserving the existing error handling.
---
Nitpick comments:
In `@internal/worktrees/worktrees.go`:
- Around line 425-428: Update the stale worktree removal error handling around
gitOutput so failures are accumulated with errors.Join instead of overwriting
lastErr. Preserve any existing error and include each new “remove worktree”
error, ensuring the final result reports all removal failures.
- Around line 159-168: Update Release to choose a valid working directory before
calling gitOutput: use path when it exists, but fall back to options.Cwd when
the locked worktree directory is missing. Preserve the existing worktree unlock
arguments and error handling so release can unlock deleted worktrees from the
main repository.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Pro
Run ID: 662cbef2-989e-4762-b009-c0b6abd056e2
📒 Files selected for processing (6)
internal/cli/app.gointernal/cli/exec.gointernal/cli/workflow_test.gointernal/cli/workflows.gointernal/worktrees/worktrees.gointernal/worktrees/worktrees_test.go
|
Addressed CodeRabbit's review on the lock-release fix.
Added regression coverage for all three. go build, go vet, and go test -race -count=1 ./internal/worktrees/... ./internal/cli/... are all clean. |
jatmn
left a comment
There was a problem hiding this comment.
I found issues that need to be addressed before this is ready.
Findings
-
[P1] Restore gofmt compliance for the changed worktree test
internal/worktrees/worktrees_test.go:342-347
The Ubuntu required smoke job stops atgofmt -l .and reports this file as unformatted, so none of its vet, test, build, or smoke stages run. Run gofmt on the changed Go sources and commit the result. -
[P1] Make the relative-release test work on macOS temporary paths
internal/cli/workflow_test.go:190-217
On macOS,t.TempDir()may be spelled through/var/...whileos.Getwd/filepath.Absreturns the physical/private/var/...path. The test compares those spellings byte-for-byte and fails in the required macOS smoke job, preventing the PR from passing CI. Compare canonical/equivalent paths (or derive the expected value with the same path-resolution behavior) instead of asserting the lexical temporary-directory spelling. -
[P1] Lock a reused worktree before handing it to a new task
internal/worktrees/worktrees.go:107-116
The reuse branch returns before the newgit worktree lockcall. A normal sequence iszero worktrees prepare task-a, release it after a prior run, then preparetask-aagain for a long-lived external caller. The second caller receives an unlocked, clean, old path; another productionPreparerunsClean, which can classify it as stale and force-remove it while that caller is waiting. Re-establish the active lease for a verified reused target and cover reuse followed by a cleanup pass. -
[P1] Do not unlock a worktree lock this exec invocation did not acquire
internal/cli/exec.go:173-181
exec --worktreedefersReleasefor both newly created and reused results, althoughPreparepermits reuse of an already locked worktree. Thus runningzero exec --worktree task-aagainst a worktree an externalzero worktrees prepare task-acaller is still using clears that caller's lock on exit; a later cleanup can force-delete the clean, idle workspace. Track whether this invocation acquired the lock (or otherwise preserve the pre-existing lock) and only release that ownership. -
[P2] Wire a repository working directory into the deleted-worktree release path
internal/cli/workflows.go:146
Releaseintentionally falls back toOptions.Cwdwhen the target directory no longer exists, but this CLI call passes an emptyOptions. Invokingzero worktrees release <deleted-path>outside the source repository consequently runsgit worktree unlockwith a non-repository working directory and leaves the orphaned lock behind forever;Cleanskips locked entries. Resolve and pass the source workspace root (and add a CLI-level deleted-path test) so the advertised recovery path works independently of the caller's current directory. -
[P2] Do not report a successful exec when releasing its worktree lock failed
internal/cli/exec.go:179-181
The deferred release error is discarded. Ifgit worktree unlockfails,zero exec --worktreecan report success while leaving a lock thatCleanwill permanently skip, recreating the disk leak this change is intended to solve with no diagnostic or remediation path. Surface the failure with the affected path, or return a failure status once the run's primary result has been emitted.
gnanam1990
left a comment
There was a problem hiding this comment.
Re-review (post 7 commits, head 323b3d2)
Thanks for the thorough follow-up — nearly everything from my Jul 11 review is addressed. The secrets scanner half looks ready. The worktree lifecycle is much safer now (deep-walk staleness, isUnderDir scoping, dirty/--ignored guards, lock-on-create, and the release path).
Prior blockers — status
- ✅ Secret redaction tail-leak / appended-suffix /
sk-admin-/ kebab-case false positives - ✅
HasPrefix→ path-boundary check; prune scoped tozero-worktree-<repoKey>/ - ✅ Exit-code checking, fail-closed inspection, ignored-file dirty detection
- ✅ Lock + release lifecycle (
worktrees release,exec --worktreedefer) ⚠️ Reuse path still skips re-locking;exec --worktreeunconditionally unlocks reused worktrees
CI regression (commit 323b3d2)
acc7361 was green; latest commit broke required Smoke:
- Ubuntu —
gofmt -l .reportsinternal/worktrees/worktrees_test.go(misaligned comments ~L453) - macOS —
TestRunWorktreesReleaseNormalizesRelativePathfails on/varvs/private/varpath spelling
Remaining correctness issues
Preparereuse skips lock (worktrees.go:107-116) — re-establish the active lease when returning a reused target.exec --worktreereleases locks it didn't acquire (exec.go:179-181) — only unlock when this invocation created/locked the worktree.
Once gofmt + macOS test are fixed and the two lifecycle edge cases are addressed, this is a fast approve from my side.
gnanam1990
left a comment
There was a problem hiding this comment.
Re-review (post 7 commits, head 323b3d2)
Thanks for the thorough follow-up — nearly everything from my Jul 11 review is addressed. The secrets scanner half looks ready. The worktree lifecycle is much safer now (deep-walk staleness, isUnderDir scoping, dirty/--ignored guards, lock-on-create, and the release path).
Prior blockers — status
- ✅ Secret redaction tail-leak / appended-suffix /
sk-admin-/ kebab-case false positives - ✅
HasPrefix→ path-boundary check; prune scoped tozero-worktree-<repoKey>/ - ✅ Exit-code checking, fail-closed inspection, ignored-file dirty detection
- ✅ Lock + release lifecycle (
worktrees release,exec --worktreedefer) ⚠️ Reuse path still skips re-locking;exec --worktreeunconditionally unlocks reused worktrees
CI regression (commit 323b3d2)
acc7361 was green; latest commit broke required Smoke:
- Ubuntu —
gofmt -l .reportsinternal/worktrees/worktrees_test.go(misaligned comments ~L453) - macOS —
TestRunWorktreesReleaseNormalizesRelativePathfails on/varvs/private/varpath spelling
Remaining correctness issues
Preparereuse skips lock (worktrees.go:107-116) — re-establish the active lease when returning a reused target.exec --worktreereleases locks it didn't acquire (exec.go:179-181) — only unlock when this invocation created/locked the worktree.
Once gofmt + macOS test are fixed and the two lifecycle edge cases are addressed, this is a fast approve from my side.
|
Pushed a33cc67 and 0c8ef19 for the latest rounds from jatmn and gnanam1990.
go build, go vet, and go test ./internal/worktrees/... ./internal/cli/... pass locally. |
jatmn
left a comment
There was a problem hiding this comment.
I found issues that need to be addressed before this is ready.
Findings
-
[P1] Do not hand a locked worktree to another active run
internal/worktrees/worktrees.go:119-131
A secondzero exec --worktree task-atreats Git's "already locked" response as an acceptable reused result and runs in the first invocation's worktree withLockAcquired=false. When the first invocation exits it releases the sole Git lock, leaving the second still-active run unprotected; a laterPreparecan then classify the clean, old tree as stale and force-remove it. The two executions also concurrently edit the same supposedly isolated checkout. Reject an in-use lease (or implement a shared lease with correct ownership) rather than returning the path to a second live caller; preserve the actual acquisition result on the create race as well. -
[P2] Validate the request before automatic cleanup
internal/worktrees/worktrees.go:52-75
ProductionPrepareinvokes destructiveCleanbefore it resolves and validatesoptions.Name. For example,zero worktrees prepare --name ../orzero exec --worktree ../can prune stale, clean Zero-owned worktrees and only then return the invalid-name error. Move cleanup after request validation so a rejected command has no cleanup side effect. -
[P2] Make deleted-worktree release usable outside the source repository
internal/cli/workflows.go:153-157
When the worktree directory has already been deleted,Releaseneeds a working directory in the source repository. This code supplies the launch directory instead; from a non-repository directory,git worktree unlock <path>fails and leaves the orphaned lock thatCleanwill skip forever. The command has no repository argument to recover from that situation. Accept/require the source repository for this recovery path (for example with--cwd) and cover the real deleted-path case.
|
Pushed 5858220 for the July 15 round.
go build, go vet, and the worktrees and cli suites pass locally. |
|
Pushed a2e2591 for the macOS and Windows smoke failures in the new validation-order test: git records worktree paths in physical spelling, so the runners' symlinked and 8.3-short temp spellings made Clean's containment check skip the test's stale entry. The test now canonicalizes its repo and base directories up front, matching what git reports. |
jatmn
left a comment
There was a problem hiding this comment.
I found issues that need to be addressed before this is ready.
Findings
-
[P1] Recover Zero-owned locks after an abnormal task exit
internal/worktrees/worktrees.go:155
Preparenow locks every created or reused worktree, whileCleanunconditionally skips every locked entry. The only automatic unlock is thezero exec --worktreein-process defer, so it is bypassed by SIGKILL, a crash, or power loss; explicitworktrees preparehas the same outcome until somebody manually releases it. That leaves an otherwise stale, clean Zero-owned checkout permanently ineligible for the automatic cleanup this PR introduces. Use a recoverable, ownership-scoped lease (for example, a PID/heartbeat marker with safe expiry handling) or another bounded recovery path that preserves live worktrees without making abandoned locks permanent. -
[P1] Canonicalize the worktree base directory before matching Git's paths
internal/worktrees/worktrees.go:424
CleanderivesrepoDirfromfilepath.Abs(options.BaseDir), which preserves a logical symlink spelling, then compares it to paths fromgit worktree list --porcelain. Git reports the physical spelling instead: with--worktree-dir /tmp/linkwherelinkpoints at/tmp/physical, a worktree added through/tmp/link/...is listed as/tmp/physical/..., soisUnderDirrejects it and it is never pruned. The latest test commit already canonicalizes its fixtures because of this Git behavior, but production still does not. Resolve the base directory (and compare normalized paths) before derivingrepoDir, and cover a symlink/alias base directory. -
[P2] Restrict
worktrees releaseto the lock Zero owns
internal/worktrees/worktrees.go:205
The new CLI accepts any path andReleasedirectly executesgit worktree unlock <path>without checking that it is inside this repository'szero-worktree-<repoKey>subtree, belongs to the source repository, or carries a Zero-owned lease. Consequently,zero worktrees release /path/to/manual-worktreeremoves an operator's manual lock even though the command promises to release a worktree created byprepare; that can expose the checkout to concurrent use or later deletion. Validate Zero ownership and repository identity before unlocking, with a denial test for manually locked worktrees.
…worktrees 1. Prevent trailing redaction leaks in github_token, aws_access_key_id, and google_api_key by adding trailing word boundaries and allowing variable lengths. Refine the openai_key pattern to cleanly distinguish legacy keys and modern prefixed keys (sk-proj-, sk-svcacct-) from ordinary kebab-case phrases. 2. Implement auto-pruning of zero-owned git worktrees older than 24 hours at the start of worktrees.Prepare to prevent indefinite disk space leaks.
…oss risk Drop the trailing \b anchor on the four secret patterns whose body class allows "-" (slack_token, google_api_key, the modern openai_key branch, jwt). \b requires a word/non-word transition, so a secret ending in "-" right before a delimiter has none, and the engine backtracked the greedy quantifier to drop that last character instead of failing the match, leaking it. The body character class already provides the real stopping boundary, so the anchor was unnecessary. Fix two issues in worktree Clean flagged in review: - Staleness was decided by the worktree directory's own mtime, which only changes when an entry is added/removed/renamed directly inside it, not when a long-running task edits existing files deeper in the tree. Clean now walks the tree and treats any recently modified entry as live, and also skips any worktree a caller has explicitly locked via git worktree lock. - baseDir ownership used a raw strings.HasPrefix, so a sibling like "<baseDir>-other" would false-match. Replaced with a filepath.Rel path-boundary check.
…n errors defaultRunGit deliberately returns a nil error alongside a nonzero CommandResult.ExitCode for a failed git invocation, so the worktree remove call must check ExitCode itself instead of trusting a nil error to mean success. Route it through gitOutput, which already does that. worktreeIsStale treated an inspection failure (an unreadable file, a WalkDir error) the same as "keep walking," which can let an incompletely-inspected worktree be judged stale. Any inspection error now makes it ineligible for removal instead.
…to owned worktrees The scanner's trailing \b anchors made a credential vanish entirely when followed by a word character outside its body class (an appended suffix like AKIA...EXTRA, or ghp_..._suffix): the fixed or unbounded-greedy quantifier had no valid word boundary to land on and the whole match failed, so the real secret reached the redaction output unredacted. Dropping the trailing anchors lets the body class itself stop the match, so the credential prefix still gets redacted even when noise follows it. Also recognize sk-admin- alongside sk-proj-/sk-svcacct- so OpenAI admin keys aren't skipped by the narrowed modern-key branch. Clean pruned any worktree under the caller-supplied BaseDir, but Prepare only ever creates worktrees under a per-repository zero-worktree-<repoKey> subtree of it. Scope pruning to that subtree so a worktree a user manages by hand elsewhere under a shared BaseDir is never force-removed. Also refuse to force-remove a worktree whose mtime looks stale but that still has uncommitted or untracked changes: a task can hold live work while waiting on a model, network, or user for longer than the staleness window without writing to the tree again.
…laims to activePath/internal was created by the same MkdirAll as the nested pkg dir but never backdated, so it kept a fresh mtime and worktreeIsStale's walk reported "not stale" as soon as it hit that directory, before ever reaching the freshly-written file two levels deeper. The test passed without actually exercising recursion past the first directory.
…s dirty Prepare never called git worktree lock, so the entry.locked skip in Clean only ever protected worktrees a human locked by hand, never zero's own; a worktree that finished committing and sat idle (e.g. waiting on a slow model or network retry) for more than 24h looked clean-and-stale and got force-removed by the mtime+dirty heuristic alone. Lock every worktree Prepare creates so it gets the same protection. worktreeIsDirty also used git status --porcelain with no --ignored, so a worktree holding only .gitignore-matched task data (credentials, generated drafts, artifacts) reported as clean and got force-removed with --force, silently discarding it. Add --ignored so those files count as dirty too.
…shed worktrees Prepare locks every worktree it creates so Clean's mtime+dirty staleness heuristic never force-removes one Zero is still using, but nothing ever unlocked it, making the automatic disk-space cleanup permanently inert. Add Release (git worktree unlock) and wire it in two ways: zero exec --worktree defers a release once its own run finishes, since that flow's use of the worktree is bound to its own process. zero worktrees prepare hands the path to a longer-lived external caller with no defined end-of-life, so a new zero worktrees release <path> subcommand lets that caller release it explicitly when done.
…k deleted worktrees Address CodeRabbit's review on the lock-release fix: - zero worktrees release now resolves its path argument to absolute before calling Release, since git worktree unlock matches against the path git recorded at creation, not whatever directory the caller happens to be running from. - Clean now aggregates removal failures with errors.Join instead of overwriting lastErr, so multiple stale worktrees failing removal in the same pass are all reported, not just the last one. - Release falls back to options.Cwd as the git working directory when the worktree path itself no longer exists (e.g. a caller deleted a locked worktree by hand instead of releasing it first), so the orphaned lock can still be cleared. Added regression coverage for all three.
- Prepare re-locks a reused worktree so Clean's staleness heuristic cannot force-remove it while the new caller is still using it; a lock already held by a live external caller is kept in place and reported through the new Result.LockAcquired field. - exec --worktree only releases the lock its own Prepare call acquired and surfaces a failed release on stderr with the affected path instead of discarding the error. - worktrees release wires the resolved workspace root into Options.Cwd so the deleted-path recovery works outside the worktree directory. - The relative-path release test derives its expected value via filepath.Abs, matching the resolution the CLI uses, so macOS /var vs /private/var spellings no longer break it.
…d release -C - Prepare rejects a worktree whose lock another run still holds, on both the reuse and the create-race paths, instead of handing a second live caller an unprotected shared checkout whose sole Git lock the first caller's exit would release. - The automatic stale-worktree pruning runs only after the request itself validates, so a rejected command (an invalid --name) has no destructive cleanup side effect; covered end to end with real git in both directions. - worktrees release accepts -C/--cwd naming the source repository, which the deleted-path recovery needs when launched outside the repo (the deleted worktree path is a one-way hash with no way back to its source).
git records worktree paths in physical form, so the CI runners' symlinked (/var -> /private/var) and 8.3-short (RUNNER~1) temp spellings made Clean's containment check skip the test's stale entry and the pruning assertion fail on macOS and Windows.
a2e2591 to
31c0e14
Compare
Summary
Fixes two Medium-severity issues:
Partial Redaction / Secret Leak on Longer or Appended Keys:
github_token,aws_access_key_id, andgoogle_api_keywere matching fixed lengths with no trailing boundary anchor. Slightly longer or appended keys would only have their prefix redacted, leaking the tail.\band allowing variable lengths ({36,}etc.) solves this.openai_keypattern to cleanly distinguish legacy keys (sk-+ 20+ alnum characters) and modern prefixed keys (sk-proj-/sk-svcacct-) from ordinary kebab-case phrases.Git Worktrees Disk Space Leak:
~/.local/state/zero/worktreesaccumulated indefinitely on the user's system, consuming substantial disk space.Cleanfunction which lists worktrees viagit worktree list --porcelain, identifies zero-owned worktrees, and callsgit worktree remove --forceon those older than 24 hours.Cleanautomatically at the start ofPreparein production.Changes
internal/secrets/scanner.goandinternal/secrets/scanner_test.go.internal/worktrees/worktrees.goandinternal/worktrees/worktrees_test.go.Test plan
go test -race ./internal/secrets/... ./internal/worktrees/...— okSummary by CodeRabbit
zero worktrees release <path>to unlock a previously prepared worktree.exec --worktreenow automatically releases the worktree lock after the command finishes.zero worktreeshelp to include the newreleaseusage.