feat(security): path-jail primitive, credential-store locking, worktree git hardening - #891
Conversation
|
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 (6)
🚧 Files skipped from review as they are similar to previous changes (6)
WalkthroughThe PR adds file-backed credential locking, a root-confined filesystem package, and hardened Git subprocess handling. It adds regression tests for concurrency, path confinement, and process cancellation. ChangesCredential-store locking
Path-jail filesystem confinement
Git subprocess hardening
Estimated code review effort: 4 (Complex) | ~60 minutes Sequence Diagram(s)sequenceDiagram
participant CredentialStore
participant LockFile
participant DataFile
CredentialStore->>LockFile: acquire lock
CredentialStore->>DataFile: read or update credentials
CredentialStore->>LockFile: release lock
sequenceDiagram
participant Caller
participant pathjail
participant osRoot
Caller->>pathjail: open confined path
pathjail->>osRoot: validate and access root-relative path
osRoot-->>Caller: return confined file handle
sequenceDiagram
participant WorktreeCaller
participant newHardenedCommand
participant GitProcessGroup
WorktreeCaller->>newHardenedCommand: create Git command
newHardenedCommand->>GitProcessGroup: configure cancellation
WorktreeCaller->>GitProcessGroup: cancel command
GitProcessGroup->>GitProcessGroup: terminate process group
GitProcessGroup-->>WorktreeCaller: return from Wait
Possibly related PRs
Suggested reviewers: 🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches🧪 Generate unit tests (beta)
Comment |
There was a problem hiding this comment.
Actionable comments posted: 5
🤖 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/credstore/concurrency_test.go`:
- Around line 13-20: Add a cross-platform regression test in the concurrency
tests that replaces the configured credential directory with a regular file,
then verifies both Store.Set and Store.Delete return the acquireFileLock setup
error. Reuse fileStore and existing test fixtures/helpers where applicable, and
assert the failure path for each method without changing successful contention
coverage.
In `@internal/credstore/filelock_unix.go`:
- Around line 35-38: Change acquireFileLock in
internal/credstore/filelock_unix.go (lines 35-38) and
internal/credstore/filelock_windows.go (lines 35-38) to return release functions
that propagate unlock and file.Close errors. In internal/credstore/credstore.go,
update Set (lines 142-146) and Delete (lines 183-187) to merge release errors
into the returned error whenever the read-modify-write operation otherwise
succeeds, ensuring cleanup failures are never reported as success.
In `@internal/pathjail/pathjail.go`:
- Around line 53-54: Remove the strings.TrimSpace calls in the path-opening flow
so Open preserves the original root and dir values when passing them to
filepath.Rel, os.MkdirAll, and os.OpenRoot. Add a regression test covering
leading and trailing spaces in both paths, verifying the intended
space-containing confinement boundary is used.
- Around line 124-125: Restrict generated temporary paths in the pathjail
creation flow around handle.OpenFile so prefix and suffix cannot escape dir;
reject path separators or validate that the joined relative path remains beneath
dir before opening it. Add regression cases in
internal/pathjail/pathjail_test.go lines 130-153 covering ../ in both prefix and
suffix, requiring errors and confirming no file is created outside dir.
In `@internal/worktrees/run_git_unix_test.go`:
- Around line 55-80: The shell script in the run_git_unix_test setup must not
interpolate pidFile directly: pass it as a positional argument to sh and
reference that parameter when writing the PID. In the grandchild-discovery
failure path, cancel the context and wait for command completion before failing
the test, rather than skipping, so spawned processes are cleaned up and the
security regression remains enforced.
🪄 Autofix
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: 29a8c0d6-40be-4ad6-9c70-bbfba0cd72a9
📒 Files selected for processing (11)
internal/credstore/concurrency_test.gointernal/credstore/credstore.gointernal/credstore/filelock_unix.gointernal/credstore/filelock_windows.gointernal/pathjail/pathjail.gointernal/pathjail/pathjail_test.gointernal/worktrees/run_git_test.gointernal/worktrees/run_git_unix.gointernal/worktrees/run_git_unix_test.gointernal/worktrees/run_git_windows.gointernal/worktrees/worktrees.go
|
@anandh8x this is the first of the 3-PR split you asked for on #829 — the independently-reversible hardening layer, with the durable-memory and orchestration PRs stacked behind it (opened in sequence as each lands, since a fork branch can't anchor an upstream base). Two questions before I open the next two:
Happy to adjust the boundaries if you'd cut them differently. |
Vasanthdev2004
left a comment
There was a problem hiding this comment.
Good change, and the package docs are the best part of it. The pathjail comment explaining why Lstat-then-act cannot work, and why a junction needs ModeIrregular rather than ModeSymlink, is real reasoning rather than decoration.
I checked the core claims instead of taking them:
os.Rootdoes refuse junction traversal (path escapes from parent).RefuseReparsedoes catch a junction as the final component.- a junction does report
irregular=true, symlink=false, so testing both bits is necessary rather than belt-and-braces.
I also went hunting for a filepath.Rel case-sensitivity bug on Windows and there isn't one: Rel uses EqualFold there, so an upper-cased root resolves fine. Scope is clean too. Against origin/main (cabfeef) this is exactly the 11 files and +900/-2 you describe. And every s.write() in credstore sits inside a lock-held function, so writer coverage is complete.
Three things worth fixing before it lands, all small.
1. The credstore lock is writers-only, and on Windows that makes locked writes fail
Get and Providers call read() with no lock. write() publishes with os.Rename, and Go's os.Open on Windows does not request FILE_SHARE_DELETE, so MoveFileEx cannot replace a file another opener still holds.
Measured cross-process at a 2ms cadence: 11 of 400 locked Set calls failed with credstore: publish: ... Access is denied, and roughly 7.5% of the reader's Get calls hit a sharing violation.
On unix this is fine, since POSIX rename does not care about open descriptors. So the two lock primitives are equivalent but the publish step around them is not: writers-only is sufficient on unix and insufficient on Windows.
Cheapest fix is to take the lock in Get and Providers too, shared if you want reader concurrency (LOCK_SH, or LockFileEx without LOCKFILE_EXCLUSIVE_LOCK).
2. RefuseReparse returns nil for a junction with a trailing separator
os.Root.Lstat on Windows resolves the terminal component when the path ends in a separator (doInRootAlwaysResolveTerminalSlash in root_windows.go). So Lstat on a junction name with a trailing separator stats the target, an ordinary directory, and the guard reports "not a link" for a name that is one.
if trimmed := strings.TrimRight(relative, `/\`); trimmed != "" {
relative = trimmed
}before the Lstat, plus a case beside the existing junction test.
3. CreateTemp pastes prefix and suffix into the path unsanitized
Separators and .. in either are path syntax rather than name text. A prefix of ..\sibling\note puts the file outside the dir the doc promises. Worse, a suffix containing .. erases the random component: .tmp\..\..\evil produced a file named literally evil at the jail root, so the unpredictability O_EXCL is there to protect is gone.
filepath.Base each of them, or reject a separator or .. outright.
Advisory, not blocking
Openresolvesrootitself by pathname (MkdirAllthenOpenRoot), so a junction planted at root or at an ancestor relocates the whole jail. Your doc calls this out as a deliberate trade-off, and the stores this replaces already create the same roots by pathname, so it is not a regression. Worth settling before splits 2 and 4 adopt it though, because "the boundary the caller chose and trusts" is a weaker claim when pathjail is the thing creating that boundary.ModeIrregularis set for every reparse tag except symlink, AF_UNIX and dedup. OneDrive placeholders and Store AppExecLink stubs fall in that set, so a store under a OneDrive-backed Documents folder would be refused outright. The stdlib carves out dedup for exactly this reason. Testing the reparse tag rather than the mode would avoid it.- On Windows only the direct
git.exeis killed on cancel, while POSIX kills the whole group. LFS filters, credential helpers and fsmonitor survive, and on Windows a survivor holding handles inside the worktree is what blocks removal. A job object withJOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSEis the analogue if you want it here, otherwise worth a line saying it is deferred. - The cancellation hardening does not currently run at all:
exec.go:205,exec.go:232,workflows.go:87andworkflows.go:177all passcontext.Background(), so theCancelhook and the cancel branch ofWaitDelayare unreachable from production. GIT_DIRand friends still pass through and take precedence overcommand.Dir. Not introduced here, since the previous code set noEnvat all and this is strictly an improvement, but "consistent environments" invites the question and an explicit filter is a few lines.- Tests: all four concurrency tests pin
Storage: "file", whileencrypted-fileis the default on every platform except macOS with a keyring, and it takes a different path throughsecurefile.Crypterwith its own nested lock. Also the junction test skips silently off Windows, wherelinkDirusesos.Symlink, so a predicate narrowed back toModeSymlinkalone would still pass there. - The "byte-for-byte reconstruction" framing does not hold for
worktrees.go. TheLC_ALL/LANGline was re-resolved against main rather than replayed, so that file deserves fresh eyes rather than split-of-already-reviewed treatment.
One to dismiss
CodeRabbit's filelock_unix.go:38 "return lock-release errors" is not real. The payload is fsync'd and renamed before release() runs, and file.Close() drops the flock even if the explicit LOCK_UN errors, so a dropped release error cannot lose data. Returning it would only force Set and Delete to choose between the write error and a close diagnostic.
Happy to re-review quickly once 1 to 3 are in. The shape of this is right and I would like it landed.
anandh8x
left a comment
There was a problem hiding this comment.
I found issues that should be addressed before this is ready.
Findings
-
[P1] Preserve legitimate whitespace in confined paths
internal/pathjail/pathjail.go:52-54Opentrims bothrootanddir, so a legitimate path whose name begins or ends with a space is silently changed to a different filesystem object beforefilepath.Rel,MkdirAll, andOpenRoot. I reproduced this with a root named"root ":Opensucceeded but created/opened"root"instead. Treat only an actually empty root as missing and preserve the caller's original path spelling. -
[P1] Keep generated temporary files inside the requested directory
internal/pathjail/pathjail.go:118-125CreateTempjoins caller-controlledprefixandsuffixwithout rejecting path components. A prefix such as../escapedcauses the file to be created outsidedir(though still under the broaderos.Root). I reproduced this: a request underintendedreturned anescaped.<random>.tmppath in its parent. Reject separators/traversal in both prefix and suffix, or verify the resulting relative path remains beneathdir. -
[P2] Report lock-release failures instead of returning success
internal/credstore/filelock_unix.go:35-38
internal/credstore/filelock_windows.go:35-38
internal/credstore/credstore.go:142-152,183-199Both release closures discard unlock and
Closefailures, andSet/Deleteuse a plain deferred release. A completed write can therefore be reported as fully successful even when the lock cleanup failed. Return an error from the release closure and join it into the operation result, at least when the read-modify-write otherwise succeeded. -
[P2] Make the Unix cancellation regression clean up on its failure path
internal/worktrees/run_git_unix_test.go:48-80The shell program interpolates
pidFiledirectly and thegrandchild == 0branch callst.Skipwithout cancelling and waiting for the already-started command. That can leak the shell/grandchild and also turns a failed security assertion into a skipped test. Pass the path as a positional shell argument; if PID discovery fails, cancel, wait for completion, then fail.
Scope and validation
The split is otherwise focused and the architecture is sensible: the handle-relative path boundary is reusable, the credential lock covers the complete read-modify-write transaction, and git hardening is wired through one constructor.
All GitHub checks are green. Locally, the focused package tests and vet passed, and the three affected packages cross-compiled as Windows test binaries. I could not run -race in this environment because no C compiler is installed.
The requested test for replacing the credential directory with a regular file is worthwhile regression coverage, but current Set and Delete already propagate the MkdirAll lock-setup error, so I do not consider it a separate implementation blocker.
Non-blocking: the PR description says “split 1 of 3,” while the commit message says “1/4” and references “2/4 and 4/4.” Please make the split count consistent.
fcd48b8 to
3079b06
Compare
|
Thanks both — pushed Fixed
One I'd push back on
Advisory — noted, not in this PR
I also gave Verified: |
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/pathjail/pathjail_test.go`:
- Around line 130-152: Extend TestCreateTempIsExclusiveAndUnpredictable with a
controllable random-byte source used by the internal CreateTemp implementation.
Pre-create the first generated temporary name, then call CreateTemp and verify
the existing file remains unchanged while creation retries and returns a
different name; retain the existing suffix and uniqueness assertions.
- Around line 157-170: The test TestOpenPreservesLeadingAndTrailingSpaceInPaths
should use a portable leading-space path component instead of a trailing-space
component. Create a child entry through handle, then inspect it via handle at
the expected leading-space path to verify the filesystem preserves the requested
spelling; do not rely on handle.Name() or host-specific trailing-space behavior.
🪄 Autofix
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: 206438fd-4950-476c-a97c-d7e17d0a5ba3
📒 Files selected for processing (6)
internal/credstore/credstore.gointernal/credstore/filelock_unix.gointernal/credstore/filelock_windows.gointernal/pathjail/pathjail.gointernal/pathjail/pathjail_test.gointernal/worktrees/run_git_unix_test.go
🚧 Files skipped from review as they are similar to previous changes (3)
- internal/credstore/filelock_windows.go
- internal/worktrees/run_git_unix_test.go
- internal/pathjail/pathjail.go
Vasanthdev2004
left a comment
There was a problem hiding this comment.
Re-reviewed at 3079b06c. All three of my blocking findings are genuinely fixed, and I checked each rather than taking the summary:
- credstore readers.
acquireFileLock(exclusive bool), writerstrue,GetandProvidersfalse,LOCK_SHon unix andLockFileExwithoutLOCKFILE_EXCLUSIVE_LOCKon Windows, both held underdefer release()across the read. That closes it properly: the writer now waits for readers to drop the lock instead of racing their open handle into its rename. RefuseReparsetrailing separator.TrimRightbefore the Lstat, with the root case guarded so"."survives, and a test beside the junction one.CreateTempfragments. Rejected up front, and I like that the test usesOpen(root, subdir)so it exercises the caseos.Rootdoes not backstop, where the fragment lands inside the root but outside the promiseddir. That is the version that actually proves something.
Agreed on the pushback about lock-release errors, for the reason you give.
The deferrals look right too. The ModeIrregular tag work, the GIT_DIR filter and the job object are each their own change, and wiring the cancellable context in split 3 is the correct home for it since that is where the call sites live.
One new thing, and it is the only reason this is not an approve.
TestOpenPreservesLeadingAndTrailingSpaceInPaths cannot hold on Windows
Smoke (windows-latest) is red on this head; macOS and ubuntu are green. The assertion is:
if _, err := os.Stat(filepath.Join(base, "root")); err == nil {
t.Error(`a trimmed "root" was created instead of the requested "root "`)
}Win32 strips trailing spaces and dots from path components, so "root " and "root" are not two objects there, they are one. I ran it to be sure:
os.MkdirAll(<tmp>/"root ") -> created on disk: "root"
stat("root ") err=<nil>
stat("root") err=<nil>
So the directory that appears is root, the trimmed stat succeeds, and the test reports a trim that never happened. The premise behind the finding is true on unix and simply not expressible on Windows.
Your fix is fine and I would keep it. It is the test that needs the gate: either if runtime.GOOS == "windows" { t.Skip(...) } with a line saying why, or keep the handle.Name() half everywhere and run only the trimmed-stat half off Windows. Worth a sentence about Win32 component normalization so nobody re-adds it later.
Worth naming the shape as well, since this stack has two more splits coming: this is a case only a Windows machine can see, and you are on macOS. The same will apply to anything touching path semantics in 2/3 and 3/3, so it may be worth me running those on Windows before they go up rather than after CI says so.
Everything else is green here: go build ./..., and internal/credstore and internal/worktrees both pass on Windows.
anandh8x
left a comment
There was a problem hiding this comment.
Re-reviewed the latest head (3079b06). The earlier traversal, credential-locking, and Unix cancellation blockers are substantially fixed, but this is not ready to approve yet.
Findings
-
[P1] Make the whitespace regression portable on Windows —
internal/pathjail/pathjail_test.go:157-170Windows smoke is red because the test assumes
"root "and"root"are distinct filesystem objects. Win32 normalizes the trailing-space component, so the assertion fails even thoughOpenno longer trims the supplied path. Keep the production fix, but test a portable leading-space component by creating a child through the returned handle and verifying it at the expected path. -
[P2] Preserve whitespace-only paths too —
internal/pathjail/pathjail.go:53-61Openstill usesstrings.TrimSpace(root) == ""and the same check fordir. That means a legitimate whitespace-only path is rejected as a missing root, or silently replaced withrootfordir. This is the remaining part of the original retargeting bug. Checkroot == ""anddir == ""while otherwise preserving the exact caller input. -
[P2] Exercise the exclusive-create collision path —
internal/pathjail/pathjail_test.go:130-152The implementation correctly uses
O_EXCL, but the current uniqueness loop never forces a collision. A truncating implementation with deterministic distinct names could still pass. Add a controllable random source for the internal helper, pre-create the first generated name, then verify the existing file is unchanged and creation retries with a different name. This is a regression-test gap rather than evidence that the current production path is broken.
Validation
Locally, the three focused packages passed three repeated test runs and go vet; all three also cross-compiled as Windows test binaries. Native Windows smoke currently fails at TestOpenPreservesLeadingAndTrailingSpaceInPaths. The branch is also one commit behind current main, although that commit does not overlap these files.
3079b06 to
f94d32e
Compare
|
Pushed Fixed
Each fix is mutation-verified — I reverted each guard in turn and confirmed the corresponding test fails, which is how I found the vacuous whitespace test above rather than shipping it. Also
@Vasanthdev2004 — on your offer to run 2/3 and 3/3 on Windows before they go up: yes please, and I think it's the right call. This failure is exactly the shape you named — a case only a Windows machine can see, from a macOS author — and both remaining splits touch path semantics (the memory store and the plan store are the two |
Vasanthdev2004
left a comment
There was a problem hiding this comment.
Approving at f94d32e8. Ran it on Windows, which is the machine that mattered here.
All three packages pass: internal/pathjail, internal/credstore, internal/worktrees, plus go build ./... and gofmt clean. Merge base is exactly origin/main (2d2450e9), so the rebase is real.
The leading-space substitution is the right call and it does hold on Windows. A leading space survives component normalization there, unlike a trailing one, so the test runs on all three platforms instead of skipping on the one where path semantics are most interesting. Writing through the handle and stating the file at the expected spelling is a stronger assertion than handle.Name() too.
I also mutation-checked two of your guards rather than taking the summary:
- Dropping
O_EXCLfrom the production create failsTestCreateTempRetriesWithoutClobberingAnExistingName. TherandomBytesseam does its job. - Restoring the old
TrimSpaceguard does not failTestOpenPreservesSpaceInPaths.
That second one is worth passing back, because it is the same vacuity you caught in your own first attempt, surviving in the sibling test. TrimSpace only touches whitespace at the ends of the whole string, and filepath.Join(base, " root") produces an absolute path beginning with a drive letter, so the guard is a no-op against it on every platform. I confirmed the mutation actually landed before concluding that, since a revert that silently fails to apply looks identical to a passing test:
root = strings.TrimSpace(root)
dir = strings.TrimSpace(dir)
if root == "" {
...
--- PASS: TestOpenPreservesSpaceInPaths
So the sensitive test for that fix is TestOpenAcceptsAWhitespaceOnlyComponent, and it is skipped on Windows, which leaves the whitespace guard with no Windows coverage at all. The fix itself is correct and I am not blocking on this. If you want the coverage, a relative " root" under t.Chdir would bite on both platforms, since the whitespace is then at the string boundary where TrimSpace can reach it.
Not blocking, just noting it so the next reader does not trust the comment more than the assertion.
Good round. Three iterations, each finding fixed with a regression rather than a patch, and you found one of your own vacuous tests on the way. Taking you up on running 2/3 and 3/3 here before they go up, both adopters are path-semantics code and this is the machine that sees it.
anandh8x
left a comment
There was a problem hiding this comment.
Re-reviewed latest head f94d32e8. The previous three blockers were addressed correctly, and all GitHub checks are green, including Windows. I found four remaining issues before approval.
Findings
-
[P1] Preserve valid trailing backslashes on Unix —
internal/pathjail/pathjail.go:101-108RefuseReparseunconditionally appliesstrings.TrimRight(relative,/\). On Unix, backslash is a valid filename character rather than a separator, so this inspects a different final component. I reproduced it with a symlink namedlinked\\:RefuseReparseinspectedlinkedand returnednilinstead ofErrReparse. Strip only separators recognized by the active platform, with the Windows-specific dual-separator behavior kept behind a small platform check, and add the Unix regression. -
[P2] Propagate lock-release failures —
internal/credstore/filelock_unix.go:41-44,internal/credstore/filelock_windows.go:42-45,internal/credstore/credstore.go:142-229Both release closures still discard unlock and
Closeerrors, and all four file-backed operations defer them without observing the result. This can report success after incomplete cleanup and conflicts with the repository rule that cleanup or unlock failure must not be reported as success. Return an error from the release function and join it into the result fromSet,Get,Delete, andProviders. -
[P2] Cover lock-acquisition failure paths —
internal/credstore/concurrency_test.goThe tests cover successful contention but not lock setup failure. Add a cross-platform regression that replaces the configured credential directory with a regular file and verifies both
SetandDeletereturn the acquisition error. This is required failure-path coverage for the new security boundary. -
[P2] Make the portable whitespace test mutation-sensitive —
internal/pathjail/pathjail_test.go:163-188TestOpenPreservesSpaceInPathsusesfilepath.Join(base, " root"), so the full absolute string begins with the temp-directory prefix. Restoring the oldTrimSpacebehavior therefore leaves the path unchanged and the test still passes. Aftert.Chdir, use a relative" root"so the leading whitespace is at the string boundary on every platform; then verify the handle-created child lands under that exact spelling.
Validation
Focused -race tests for internal/pathjail, internal/credstore, and internal/worktrees passed, as did focused vet, formatting, repository-wide vet, release build, and release smoke. All GitHub checks are green. The full local suite reached unrelated environment-dependent sandbox-policy failures in internal/agent and internal/cli; the affected PR packages passed.
f94d32e to
7193cac
Compare
|
Pushed Fixed
Every guard here is mutation-verified; that's how the vacuous test above got caught rather than shipped a second time. Validation
@Vasanthdev2004 — thanks for running it on Windows and for mutation-checking rather than trusting my summary; that's what turned up the dead assertion. The offer to run 2/3 and 3/3 on Windows before they go up still stands as far as I'm concerned, and I'll take you up on it. |
Vasanthdev2004
left a comment
There was a problem hiding this comment.
Approving again at 7193cac7. Ran it on Windows.
go build ./..., go vet, gofmt clean, and internal/pathjail, internal/credstore and internal/worktrees all pass. Merge base is still exactly origin/main (2d2450e9).
The vacuous whitespace test is genuinely fixed, and I checked it the same way as before. Restoring the old TrimSpace guard now FAILS TestOpenPreservesSpaceInPaths, where on the previous head it passed. I confirmed the mutation landed before drawing that conclusion. Moving the path under t.Chdir so the space sits at the start of the whole string is the right move: that is the only position TrimSpace can reach, which is precisely why the absolute-path version could never bite. The comment you left on it says exactly that, which is what stops someone reinstating the weaker form later.
On the backslash finding, credit where it is due: @anandh8x caught a bug I introduced. The TrimRight(relative, `/\`) in my previous review is what put it there. Backslash is a separator only on Windows, so on unix that trims a legal filename character and inspects linked when asked about a symlink genuinely named linked\, reporting "not a link" for one that is. Routing through os.IsPathSeparator is correct by construction rather than by enumeration, and keeping it as one function with the conditional inside is the right shape.
Being explicit about what I did and did not verify: I confirmed the helper's shape, that Windows behaviour is unchanged, and that the new regression is present and correctly gated. I did not execute the unix path, since this machine cannot run it, so that half rests on @anandh8x's review and on CI rather than on me.
The lock-release change reads well too. Reporting the unlock error while keeping Close unconditional means a cleanup that did not complete is no longer indistinguishable from one that did, and joining it at the callers annotates a successful write rather than masking it. Better than either of the two positions we were arguing between.
Four rounds, every finding closed with a regression that fails without the fix, and two vacuous tests caught along the way (one by you, one by me, the same shape both times). Good to land once @anandh8x clears his outstanding review; his findings look addressed to me, but the approval is his to give.
anandh8x
left a comment
There was a problem hiding this comment.
Re-reviewed latest head 7193cac. The previously requested fixes are correct: Unix backslash names are preserved, lock release errors are propagated, lock-acquisition failures are covered, and the whitespace regression is now mutation-sensitive. Focused race tests, vet, Windows cross-compilation, formatting, build, smoke, and all GitHub checks pass across Linux, macOS, and Windows. The deterministic release-failure regression remains worthwhile follow-up coverage, but it is not evidence of a production defect and is non-blocking.
Split 2/3 of Gitlawb#829, stacked on the pathjail primitive from Gitlawb#891 (1/3). - internal/memory: a durable note store confined to a pathjail handle, so every read, write, rename and delete stays on the confined handle rather than being re-resolved by pathname. Writes publish through an O_EXCL temporary file, and the final component is checked for a reparse point before each write. - internal/tools/memory.go: the memory tool over that store. Note names are validated by an ALLOW-LIST (^[a-zA-Z0-9_-]+$, the same rule plan names use), so a name can carry neither a path separator nor a traversal. The pathjail fragment rejection underneath it is therefore defence in depth rather than the only guard, and the two layers cannot disagree. Registration into the tool registry lands with the orchestration wiring in 3/3, where the shared registry changes live.
Split 2/3 of Gitlawb#829, stacked on the pathjail primitive from Gitlawb#891 (1/3). - internal/memory: a durable note store confined to a pathjail handle, so every read, write, rename and delete stays on the confined handle rather than being re-resolved by pathname. Writes publish through an O_EXCL temporary file, and the final component is checked for a reparse point before each write. - internal/tools/memory.go: the memory tool over that store. Note names are an ALLOW-LIST matching the plan store's rule (^[a-z][a-z0-9-]{0,63}$), and lowercase-only is load bearing: Windows and a default APFS volume fold case, so "Findings" and "findings" would be one file there — the second write silently replacing the first's body and a delete of one removing the other. Reserved DOS device names are refused on top of the pattern, because os.Root addresses notes relative to a handle and so creates "con.md" happily, while `git add -A` then fails on that path and stages nothing, and a repo carrying one cannot be checked out on Windows at all. Frontmatter parses CRLF as well as LF: project notes are checked in, and Git for Windows defaults to autocrlf=true, so a note that merely round-trips through a clone came back with its header rendered as body and no description. Registration into the tool registry lands with the orchestration wiring in 3/3, where the shared registry changes live.
|
@kevincodex1 this one is ready from our side and worth merging ahead of the queue, but GitHub reports it as blocked and I do not have the rights to see why. State at What is holding it: Why it is worth doing first: it is 1 of 3 in a stack. #897 sits directly on top of it and #829's orchestration remainder behind that, so merging this unblocks a chain rather than a single PR. It also carries the Four rounds of review on it. Every finding was closed with a regression that fails without its fix, and I ran the last two heads on Windows myself since the author is on macOS and the path semantics only misbehave here. No rush from me if you would rather wait on the CodeRabbit check, just flagging that it does not look like it is going to report on its own. |
|
Tip For best results, initiate chat on the files or code changes. 🧩 Analysis chain🏁 Script executed: #!/bin/bash
set -euo pipefail
repo="Gitlawb/zero"
pr="891"
echo "== Pull request state =="
gh pr view "$pr" --repo "$repo" --json number,headRefOid,baseRefOid,mergeStateStatus,mergeable,reviewDecision,statusCheckRollup \
--jq '{number, headRefOid, baseRefOid, mergeStateStatus, mergeable, reviewDecision, statusCheckRollup: [.statusCheckRollup[] | {name, status, conclusion, detailsUrl, workflowName}]}'
echo
echo "== Check runs for PR head =="
head_sha="$(gh pr view "$pr" --repo "$repo" --json headRefOid --jq '.headRefOid')"
gh api "repos/$repo/commits/$head_sha/check-runs?per_page=100" \
--jq '.check_runs[] | {name, status, conclusion, started_at, completed_at, details_url, app: .app.slug}'
echo
echo "== Combined commit status =="
gh api "repos/$repo/commits/$head_sha/status" \
--jq '{state, statuses: [.statuses[] | {context, state, target_url, creator: .creator.login}]}'
echo
echo "== Branch protection visibility =="
gh api "repos/$repo/branches/main/protection" --jq '{required_status_checks, required_pull_request_reviews}' \
|| echo "Branch-protection details are not available to the current GitHub token."Length of output: 4797
GitHub reports the following state for
The null The remaining block is a repository rule that the available token cannot read. The branch-protection endpoint returned HTTP 403. A repository administrator should inspect the 🧠 Learnings usedYou are interacting with an AI system. |
|
@kevincodex1 correcting myself: the CodeRabbit check is not what is blocking this, and my message above was wrong about the cause. I checked it properly instead of inferring. That check is pending on all eight open PRs, and it is also pending on #890 and #887, both of which merged. So it is not a required check and it does not block anything. What I can still say, from outside branch protection:
So something in Sorry for the noise. The ask is unchanged and the reason to prioritise it stands: 1 of 3, with #897 approved and stacked directly on it, and #829's remainder behind that. |
|
Tip For best results, initiate chat on the files or code changes.
The pending An administrator must inspect the The priority rationale for You are interacting with an AI system. |
Split 2/3 of Gitlawb#829, stacked on the pathjail primitive from Gitlawb#891 (1/3). - internal/memory: a durable note store confined to a pathjail handle, so every read, write, rename and delete stays on the confined handle rather than being re-resolved by pathname. Writes publish through an O_EXCL temporary file, and the final component is checked for a reparse point before each write. - internal/tools/memory.go: the memory tool over that store. Note names are an ALLOW-LIST matching the plan store's rule (^[a-z][a-z0-9-]{0,63}$), and lowercase-only is load bearing: Windows and a default APFS volume fold case, so "Findings" and "findings" would be one file there — the second write silently replacing the first's body and a delete of one removing the other. Reserved DOS device names are refused on top of the pattern, because os.Root addresses notes relative to a handle and so creates "con.md" happily, while `git add -A` then fails on that path and stages nothing, and a repo carrying one cannot be checked out on Windows at all. Frontmatter parses CRLF as well as LF: project notes are checked in, and Git for Windows defaults to autocrlf=true, so a note that merely round-trips through a clone came back with its header rendered as body and no description. Registration into the tool registry lands with the orchestration wiring in 3/3, where the shared registry changes live.
Split 2/3 of Gitlawb#829, stacked on the pathjail primitive from Gitlawb#891 (1/3). - internal/memory: a durable note store confined to a pathjail handle, so every read, write, rename and delete stays on the confined handle rather than being re-resolved by pathname. Writes publish through an O_EXCL temporary file. - internal/tools: memory (read/list), memory_write (save), memory_forget (delete). Reads are confined by the same rule as writes: RefuseReparse now runs on the read path too, closing a link at the note position that resolves back inside the root — os.Root permits that case, so it was served. The guard is reparse-point based, not identity based, and says so: a hard link carries no reparse bit and still reads through. The size ceiling holds on the way out as well as in. A note that arrived by hand or through a clone was previously read whole however large, and List did that for every note in the store. Scope is resolved in ONE place (ResolveScopes). The two paths disagreed: an unrecognised spelling widened a named read to both stores while the listing ignored a valid scope and always read both. Operational failures reach the caller instead of being reported as absence. Only ErrNotFound is a miss; a refused link, an oversized note or a permission error is an error, because "no such note" is what makes the model write it again. Deletion is its own tool. memory_write's approval says it saves a note, and an omitted or whitespace-only content used to fall through to Forget and destroy one under that sentence — with "always allow" making it unattended. content is now required and non-empty, and memory_forget carries its own disclosure. The local store makes itself private on first write rather than relying on the workspace's .gitignore, which would protect one repository rather than every one the tool runs in. Origin-Session: local-7a41bc | Claude Code | 1 prompt
Split 2/3 of Gitlawb#829, stacked on the pathjail primitive from Gitlawb#891 (1/3). - internal/memory: a durable note store confined to a pathjail handle, so every read, write, rename and delete stays on the confined handle rather than being re-resolved by pathname. Writes publish through an O_EXCL temporary file. - internal/tools: memory (read/list), memory_write (save), memory_forget (delete). Reads are confined by the same rule as writes: RefuseReparse now runs on the read path too, closing a link at the note position that resolves back inside the root — os.Root permits that case, so it was served. The guard is reparse-point based, not identity based, and says so: a hard link carries no reparse bit and still reads through. The size ceiling holds on the way out as well as in. A note that arrived by hand or through a clone was previously read whole however large, and List did that for every note in the store. Scope is resolved in ONE place (ResolveScopes). The two paths disagreed: an unrecognised spelling widened a named read to both stores while the listing ignored a valid scope and always read both. Operational failures reach the caller instead of being reported as absence. Only ErrNotFound is a miss; a refused link, an oversized note or a permission error is an error, because "no such note" is what makes the model write it again. Deletion is its own tool. memory_write's approval says it saves a note, and an omitted or whitespace-only content used to fall through to Forget and destroy one under that sentence — with "always allow" making it unattended. content is now required and non-empty, and memory_forget carries its own disclosure. The local store makes itself private on first write rather than relying on the workspace's .gitignore, which would protect one repository rather than every one the tool runs in. Origin-Session: local-7a41bc | Claude Code | 1 prompt
… and Gitlawb#897 Vasanth's review of this branch found that it carries OLDER copies of code under active review elsewhere, so merging it would silently revert those reviews: internal/pathjail/pathjail.go 135 lines here, 177 on Gitlawb#891's head internal/memory/memory.go 333 lines here, 539 on Gitlawb#897's head The memory copy was not merely older. Its name allow-list was ^[a-zA-Z0-9_-]+$, which lets "Findings" and "findings" collide into one file on Windows and default APFS — the case-collision bug Gitlawb#897 fixed, where a write silently replaced the other's body and a delete removed the wrong note. The reserved-device-name refusal was absent too, so a note named "con" or "nul" would make `git add -A` stage nothing and leave the repo un-checkoutable on Windows. This branch now takes those three packages verbatim from Gitlawb#897's reviewed head (which contains Gitlawb#891's), rather than keeping a fork of them. Two consequences had to be handled: - internal/tools/memory_tool_test.go asserted the OLD destructive behaviour (TestOmittingContentForgetsTheNote), i.e. it encoded the very defect Gitlawb#897 fixed. Replaced with one asserting deletion goes through memory_forget and that a save-shaped call with no content is refused rather than destructive. - internal/cli/app.go registered the read and write tools but not the new memory_forget, which would have left notes undeletable. Registered. Also merges current main, resolving the overlap with Gitlawb#890 so both features survive rather than one replacing the other: the footer keeps the fast chip AND the zeromaxing chip as independent conditions, agent.Options keeps both ServiceTier and ModelFamily, the provider-models JSON keeps both the capability keys and the probe-verdict keys, and session_controls keeps both the catalog-discovery efforts and the posture-fill decision. A merge rather than a rebase deliberately: replaying 131 commits over the overlap resolves the same regions ~30 times, and a wrong side taken once in that grind is exactly the silent revert this commit exists to prevent.
… and Gitlawb#897 Vasanth's review found this branch carrying OLDER copies of code under active review elsewhere, so merging it would silently revert those reviews: internal/pathjail/pathjail.go 135 lines here, 177 on Gitlawb#891's head internal/memory/memory.go 333 lines here, 539 on Gitlawb#897's head The memory copy was not merely older. Its name allow-list was ^[a-zA-Z0-9_-]+$, which lets "Findings" and "findings" collide into one file on Windows and default APFS — the case-collision bug Gitlawb#897 fixed, where a write silently replaced the other's body and a delete removed a note the caller never named. The reserved-device-name refusal was absent too, so a note called "con" or "nul" would make `git add -A` stage nothing and leave the repo un-checkoutable on Windows. Those three packages are now taken verbatim from Gitlawb#897's reviewed head (which contains Gitlawb#891's) rather than kept as a fork. Two consequences had to be handled: - internal/tools/memory_tool_test.go asserted the OLD destructive behaviour (TestOmittingContentForgetsTheNote) — a test encoding the very defect Gitlawb#897 fixed, which is what would make a careless resolve look correct. Replaced with one asserting deletion goes through memory_forget and that a save-shaped call with no content is refused. - internal/cli/app.go registered the read and write tools but not the new memory_forget, which would have left notes undeletable. Registered. Origin-Session: local-de382f | Claude Code | 3 prompts Origin-Snapshot: c28c274718fa
…ee git hardening Split 1/3 of Gitlawb#829, carved out as the independently-reversible hardening layer with no dependency on the orchestration work that stacks on top of it. - internal/pathjail: os.Root handle-relative path confinement — ancestor traversal is confined, the final component is checked for reparse points (including a name written with a trailing separator), unprivileged Windows junctions are covered, temp-name fragments are rejected before they can escape the directory, whitespace-bearing paths are confined as spelled, and temp files stay unpredictable and exclusive. - internal/credstore: file locking around the credential read-modify-write — exclusive for writers, shared for readers, so a reader cannot race a publish. - internal/worktrees: git invocation hardening. pathjail has no importers in this PR by design; internal/memory and internal/specialist adopt it in the stacked PRs (2/3 and 3/3). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Origin-Session: local-de382f | Claude Code | 4 prompts Origin-Snapshot: 52db9b74dad5
7193cac to
2c1b82d
Compare
Split 2/3 of Gitlawb#829, stacked on the pathjail primitive from Gitlawb#891 (1/3). - internal/memory: a durable note store confined to a pathjail handle, so every read, write, rename and delete stays on the confined handle rather than being re-resolved by pathname. Writes publish through an O_EXCL temporary file. - internal/tools: memory (read/list), memory_write (save), memory_forget (delete). Reads are confined by the same rule as writes: RefuseReparse now runs on the read path too, closing a link at the note position that resolves back inside the root — os.Root permits that case, so it was served. The guard is reparse-point based, not identity based, and says so: a hard link carries no reparse bit and still reads through. The size ceiling holds on the way out as well as in. A note that arrived by hand or through a clone was previously read whole however large, and List did that for every note in the store. Scope is resolved in ONE place (ResolveScopes). The two paths disagreed: an unrecognised spelling widened a named read to both stores while the listing ignored a valid scope and always read both. Operational failures reach the caller instead of being reported as absence. Only ErrNotFound is a miss; a refused link, an oversized note or a permission error is an error, because "no such note" is what makes the model write it again. Deletion is its own tool. memory_write's approval says it saves a note, and an omitted or whitespace-only content used to fall through to Forget and destroy one under that sentence — with "always allow" making it unattended. content is now required and non-empty, and memory_forget carries its own disclosure. The local store makes itself private on first write rather than relying on the workspace's .gitignore, which would protect one repository rather than every one the tool runs in. Origin-Session: local-7a41bc | Claude Code | 1 prompt Origin-Session: local-de382f | Claude Code | 4 prompts Origin-Snapshot: 52db9b74dad5
… and Gitlawb#897 Vasanth's review found this branch carrying OLDER copies of code under active review elsewhere, so merging it would silently revert those reviews: internal/pathjail/pathjail.go 135 lines here, 177 on Gitlawb#891's head internal/memory/memory.go 333 lines here, 539 on Gitlawb#897's head The memory copy was not merely older. Its name allow-list was ^[a-zA-Z0-9_-]+$, which lets "Findings" and "findings" collide into one file on Windows and default APFS — the case-collision bug Gitlawb#897 fixed, where a write silently replaced the other's body and a delete removed a note the caller never named. The reserved-device-name refusal was absent too, so a note called "con" or "nul" would make `git add -A` stage nothing and leave the repo un-checkoutable on Windows. Those three packages are now taken verbatim from Gitlawb#897's reviewed head (which contains Gitlawb#891's) rather than kept as a fork. Two consequences had to be handled: - internal/tools/memory_tool_test.go asserted the OLD destructive behaviour (TestOmittingContentForgetsTheNote) — a test encoding the very defect Gitlawb#897 fixed, which is what would make a careless resolve look correct. Replaced with one asserting deletion goes through memory_forget and that a save-shaped call with no content is refused. - internal/cli/app.go registered the read and write tools but not the new memory_forget, which would have left notes undeletable. Registered. Origin-Session: local-de382f | Claude Code | 3 prompts Origin-Snapshot: c28c274718fa
Split 2/3 of Gitlawb#829, stacked on the pathjail primitive from Gitlawb#891 (1/3). - internal/memory: a durable note store confined to a pathjail handle, so every read, write, rename and delete stays on the confined handle rather than being re-resolved by pathname. Writes publish through an O_EXCL temporary file. - internal/tools: memory (read/list), memory_write (save), memory_forget (delete). Reads are confined by the same rule as writes, the size ceiling holds on the way out as well as in, scope is resolved in one place, and deletion is its own tool so its approval can disclose it — memory_write's says only that it saves, and an omitted or blank content used to fall through to Forget under that sentence. PARTIAL SUCCESS IS PRESERVED END TO END. The earlier fix for "errors reported as absence" overshot in two places, and both are corrected here: - The listing returned an error INSTEAD of the notes whenever any note failed, so the library's careful partial success became total failure one unreadable directory entry away from an empty memory. The failure is now appended to the rendered notes rather than substituted for them. - The named read returned on the first non-ErrNotFound failure, and project is searched before local. So one unreadable project note called "findings" made the user's own local "findings" unreachable — the shared, externally-supplied scope masking the private one. Failures are carried and reported only when nothing readable turns up. A description is bounded separately from the note. The listing prints every description, so the field is shared screen space: the total-size check alone let one note carry a 60 KiB single-line description and consume the listing everyone else has to fit in. Origin-Session: local-de382f | Claude Code | 9 prompts Origin-Snapshot: 92ae33c95cd1
Split 2/3 of Gitlawb#829, stacked on the pathjail primitive from Gitlawb#891 (1/3). - internal/memory: a durable note store confined to a pathjail handle, so every read, write, rename and delete stays on the confined handle rather than being re-resolved by pathname. Writes publish through an O_EXCL temporary file. - internal/tools: memory (read/list), memory_write (save), memory_forget (delete). Reads are confined by the same rule as writes, the size ceiling holds on the way out as well as in, scope is resolved in one place, and deletion is its own tool so its approval can disclose it — memory_write's says only that it saves, and an omitted or blank content used to fall through to Forget under that sentence. PARTIAL SUCCESS IS PRESERVED END TO END. The earlier fix for "errors reported as absence" overshot in two places, and both are corrected here: - The listing returned an error INSTEAD of the notes whenever any note failed, so the library's careful partial success became total failure one unreadable directory entry away from an empty memory. The failure is now appended to the rendered notes rather than substituted for them. - The named read returned on the first non-ErrNotFound failure, and project is searched before local. So one unreadable project note called "findings" made the user's own local "findings" unreachable — the shared, externally-supplied scope masking the private one. Failures are carried and reported only when nothing readable turns up. A description is bounded separately from the note. The listing prints every description, so the field is shared screen space: the total-size check alone let one note carry a 60 KiB single-line description and consume the listing everyone else has to fit in. Origin-Session: local-de382f | Claude Code | 9 prompts Origin-Snapshot: 92ae33c95cd1
… and Gitlawb#897 Vasanth's review found this branch carrying OLDER copies of code under active review elsewhere, so merging it would silently revert those reviews: internal/pathjail/pathjail.go 135 lines here, 177 on Gitlawb#891's head internal/memory/memory.go 333 lines here, 539 on Gitlawb#897's head The memory copy was not merely older. Its name allow-list was ^[a-zA-Z0-9_-]+$, which lets "Findings" and "findings" collide into one file on Windows and default APFS — the case-collision bug Gitlawb#897 fixed, where a write silently replaced the other's body and a delete removed a note the caller never named. The reserved-device-name refusal was absent too, so a note called "con" or "nul" would make `git add -A` stage nothing and leave the repo un-checkoutable on Windows. Those three packages are now taken verbatim from Gitlawb#897's reviewed head (which contains Gitlawb#891's) rather than kept as a fork. Two consequences had to be handled: - internal/tools/memory_tool_test.go asserted the OLD destructive behaviour (TestOmittingContentForgetsTheNote) — a test encoding the very defect Gitlawb#897 fixed, which is what would make a careless resolve look correct. Replaced with one asserting deletion goes through memory_forget and that a save-shaped call with no content is refused. - internal/cli/app.go registered the read and write tools but not the new memory_forget, which would have left notes undeletable. Registered. Origin-Session: local-de382f | Claude Code | 3 prompts Origin-Snapshot: c28c274718fa
Split 1 of 3 of #829 — the independently-reversible hardening layer
Per @anandh8x's review of #829, splitting the change into focused PRs. This is the base: the security/hardening layer, which has no dependency on the orchestration work and carries its own independent rollback domain. The orchestration and durable-memory work stack on top of it.
What this contains
internal/pathjail(new) —os.Roothandle-relative path confinement: ancestor traversal is confined, the final component is checked for reparse points, unprivileged Windows junctions are covered, and temp files are unpredictable and exclusive. This is the path-containment primitive @anandh8x verified as correct on feat(specialist): zeromaxing posture and orchestrate plan execution #829.internal/credstore— cross-process file locking around the credential store.internal/worktrees— git invocation hardening.pathjailhas no importers in this PR by design;internal/memory(split 2) andinternal/specialist(split 3) adopt it in the stacked PRs.On the split shape
The full seven-way split @anandh8x proposed isn't mechanically achievable as independently-building PRs: the plan subsystem is a single package whose 29
plan_*.gofiles cross-reference each other, and the zeromaxing posture reaches into the shared TUI model — so "plan core", "saved plans", "model routing" and "posture" cannot compile as separate PRs without the module-seam refactor requested separately. The feasible stack is three: (1) this hardening layer → (2) durable memory → (3) the orchestration remainder, which together reconstruct #829 byte-for-byte.Validation
go build ./...clean.go test ./internal/pathjail/ ./internal/credstore/ ./internal/worktrees/— all green.main(cabfeefc).Part of #829.
Summary by CodeRabbit
Bug Fixes
Security
Reliability