Background
The rm builtin (added in #537) guards against directory removal without -d using a LstatFile pre-check followed by callCtx.Remove. On Linux this is safe: unlinkat(fd, name, 0) returns EISDIR for any directory, so even if the path is swapped between the two calls, the kernel rejects the Remove. On macOS however, os.Root.Remove has an internal EPERM→AT_REMOVEDIR fallback, meaning it succeeds on empty directories — so a concurrent process that replaces the target file with an empty directory between LstatFile and Remove could cause the directory to be deleted even without -d.
What needs to change
Add a Sandbox.RemoveFile method that calls unlinkat(fd, name, 0) directly (no AT_REMOVEDIR fallback). This makes the "remove a file" operation atomically file-only on all platforms:
- Linux: returns
EISDIR for directories (already true today via os.Root.Remove)
- macOS: returns
EPERM for directories (currently masked by Go's fallback)
Then wire RemoveFile as a second CallContext capability alongside Remove, and use it in the rm builtin when -d is not supplied. Keep Remove (which allows directories) for the -d path.
Scope
allowedpaths/sandbox.go: add Sandbox.RemoveFile(path, cwd string) error
builtins/builtins.go: add RemoveFile func(ctx context.Context, path string) error to CallContext
interp/runner_exec.go: wire RemoveFile in the remediation-mode block
analysis/symbols_builtins.go: add "RemoveFile" to callCtxAllFields and builtinPerCommandCallContextFields["rm"]
builtins/rm/rm.go: use callCtx.RemoveFile when !allowDir, callCtx.Remove when allowDir
- Tests: add pentest case that wins the TOCTOU race (swap file→dir between stat and remove) and verifies directory survives
Priority
Low — not exploitable on Linux (production platform). macOS-only risk with a narrow race window and a weak threat model (attacker needs concurrent write access inside AllowedPaths).
Refs: #537 (original rm PR), Codex review comment
Background
The
rmbuiltin (added in #537) guards against directory removal without-dusing aLstatFilepre-check followed bycallCtx.Remove. On Linux this is safe:unlinkat(fd, name, 0)returnsEISDIRfor any directory, so even if the path is swapped between the two calls, the kernel rejects the Remove. On macOS however,os.Root.Removehas an internalEPERM→AT_REMOVEDIRfallback, meaning it succeeds on empty directories — so a concurrent process that replaces the target file with an empty directory betweenLstatFileandRemovecould cause the directory to be deleted even without-d.What needs to change
Add a
Sandbox.RemoveFilemethod that callsunlinkat(fd, name, 0)directly (noAT_REMOVEDIRfallback). This makes the "remove a file" operation atomically file-only on all platforms:EISDIRfor directories (already true today viaos.Root.Remove)EPERMfor directories (currently masked by Go's fallback)Then wire
RemoveFileas a secondCallContextcapability alongsideRemove, and use it in thermbuiltin when-dis not supplied. KeepRemove(which allows directories) for the-dpath.Scope
allowedpaths/sandbox.go: addSandbox.RemoveFile(path, cwd string) errorbuiltins/builtins.go: addRemoveFile func(ctx context.Context, path string) errortoCallContextinterp/runner_exec.go: wireRemoveFilein the remediation-mode blockanalysis/symbols_builtins.go: add"RemoveFile"tocallCtxAllFieldsandbuiltinPerCommandCallContextFields["rm"]builtins/rm/rm.go: usecallCtx.RemoveFilewhen!allowDir,callCtx.RemovewhenallowDirPriority
Low — not exploitable on Linux (production platform). macOS-only risk with a narrow race window and a weak threat model (attacker needs concurrent write access inside
AllowedPaths).Refs: #537 (original
rmPR), Codex review comment