◉CLAUDE LABJP
●2.1.301 — Adds the vim % motion, LaTeX math rendering in the VS Code panel, and a retry-watchdog limit for usage-limit waits (10/15)●2.1.299 — Conversations over 200K tokens left idle about 54 minutes get compacted; MCP allow rules now match only the server they name●SONNET 4.5 — Retires on the Claude API on 11/30, 45 days left. Migrate to Sonnet 5.5●WINDOWS — Reports say hooks break silently only on Japanese-locale Windows; check the character encoding first●FAST MODE — Opus 5.5 fast mode is out of research preview and no longer needs the beta header (10/15)●NEW — I Put a Ten-Line Promise File at the To…●2.1.301 — Adds the vim % motion, LaTeX math rendering in the VS Code panel, and a retry-watchdog limit for usage-limit waits (10/15)●2.1.299 — Conversations over 200K tokens left idle about 54 minutes get compacted; MCP allow rules now match only the server they name●SONNET 4.5 — Retires on the Claude API on 11/30, 45 days left. Migrate to Sonnet 5.5●WINDOWS — Reports say hooks break silently only on Japanese-locale Windows; check the character encoding first●FAST MODE — Opus 5.5 fast mode is out of research preview and no longer needs the beta header (10/15)●NEW — I Put a Ten-Line Promise File at the To…
Articles/Claude Code
⟐ Claude Code/2026-05-27Intermediate

When Claude Code's Bash Tool Hits Permission Denied on /tmp — A $HOME/repos Fallback Pattern

A practical look at why git clone inside Claude Code's sandboxed Bash sometimes fails with Permission denied on /tmp, and how a tiny $HOME/repos fallback keeps unattended schedules alive across four indie sites.

Claude Code264Bash5sandbox9git18troubleshooting92

The morning log opens with a single line: fatal: could not create work tree dir '/tmp/repos/claudelab.net': Permission denied. The runner is healthy, the GitHub token is valid, the disk has room. What is actually happening is that a leftover subdirectory under /tmp/repos from a previous session is still owned by nobody:nogroup, and the new session's user can neither write into it nor remove it.

As Dolice Labs I run four sites — Claude Lab, Gemini Lab, Antigravity Lab, and Rork Lab — on autonomous Claude Code schedules. Each site publishes four articles a day from a sandboxed VM where the run-as user can shift between sessions. Over half a year of operation I have hit this /tmp ownership edge case more than a dozen times. Below is what I have settled on: a minimum $HOME/repos fallback that keeps unattended runs alive without any human intervention.

What the failure looks like

The clone fails with Permission denied, not No space left on device:

$ git clone --depth 1 https://...@github.com/masakihirokawa/claudelab.net.git /tmp/repos/claudelab.net
Cloning into '/tmp/repos/claudelab.net'...
fatal: could not create work tree dir '/tmp/repos/claudelab.net': Permission denied

And the directory listing reveals foreign ownership:

$ ls -la /tmp/repos/
drwxr-xr-x  3 nobody nogroup 4096 May 25 07:34 .
drwxrwxrwt 16 nobody nogroup 4096 May 27 01:34 ..
drwxr-xr-x  7 nobody nogroup 4096 May 25 07:34 antigravitylab.net

The current user is no longer nobody, so writing into the existing tree is rejected. rm -rf fails for the same reason.

Why it happens

In the sandboxed VM, the executing user can rotate between sessions. A previous session created /tmp/repos/{site} while running as nobody:nogroup and then shut down without cleaning up. When a fresh session boots under a different user, ownership of those leftover paths cannot be inherited. /tmp itself is sticky-bit world-writable (drwxrwxrwt), so new top-level directories are fine; only the orphaned subtree from the prior run is locked.

This is easy to confuse with ENOSPC. The distinguishing signal is the exact error string: No space left on device versus Permission denied. The first one calls for cache cleanup; the second calls for switching where you write to.

The minimum fallback

The three lines I have baked into every site's SKILL.md are these:

WORK="/tmp/repos/${SITE}"
 
# Verify writability for real (handles both permissions and capacity)
if ! mkdir -p "$WORK" 2>/dev/null; then
  WORK="$HOME/repos/${SITE}"
  mkdir -p "$WORK"
fi
 
echo "WORK=$WORK"

The key detail is exercising mkdir -p and checking its exit code instead of relying on test -w /tmp/repos. The simple test passes when the parent directory is writable, even if the child path you actually need is owned by someone else. I spent two consecutive mornings of empty publishing logs before I learned to verify by doing rather than by asking.

Clone vs. pull

Once the working path is settled, branch on whether a checkout already exists:

WS="$(ls -d /sessions/*/mnt/Dolice\ Labs 2>/dev/null | head -1)"
TOKEN=$(grep -A1 "^${SITE_LABEL}" "${WS}/_documents/_github_tokens/github_tokens.txt" \
        | tail -1 | tr -d '[:space:]')
 
if [ -d "$WORK/.git" ]; then
  cd "$WORK" && git pull --rebase origin main 2>&1 | tail -5
else
  git clone --depth 1 \
    "https://${TOKEN}@github.com/masakihirokawa/${SITE}.git" "$WORK" \
    2>&1 | tail -5
fi

--depth 1 is plenty for an article-only workflow. Cloudflare's CI runs generate-content.mjs from a prebuild hook, so there is no need to npm install or boot Next.js locally just to push one MDX file. The on-disk footprint of a working repo can stay under a few tens of MB.

Disk pressure on the fallback path

Falling back to $HOME/repos only helps if home has room. I keep a sanity check up front and drop sibling site repos before they start trampling on each other:

FREE_MB=$(df "$HOME" --output=avail -m 2>/dev/null | tail -1 | tr -d ' ')
if [ "${FREE_MB:-0}" -lt 500 ]; then
  for OTHER in "$HOME/repos"/*; do
    [ "$OTHER" != "$WORK" ] && rm -rf "$OTHER" 2>/dev/null
  done
fi

Because the same VM is sometimes reused between my 02:00 premium slot and my 15:00 daily slot, a stray node_modules from an earlier task could swallow 800MB on its own. Running the cleanup logic first made the rest of the pipeline noticeably more stable.

Why I have needed this since 2014

I have been shipping indie iPhone and Android apps since 2014 — Beautiful HD Wallpapers and a healing-focused catalog among them — and the lifetime download count crossed 50 million somewhere along the way. Sustaining AdMob revenue while also maintaining a contemporary-art practice means automating the blog pipeline as far as it will go. Trading hand-written posts for a Claude Code schedule was what made 4 sites × 4 posts a day feasible.

That said, an unattended pipeline that goes dark for a day directly affects the monthly Stripe membership numbers. The first time a single Permission denied killed two mornings in a row, I ended up patching it from my iPhone at a Kichijoji café. Since then, not trusting /tmp and verifying writability on the fallback path have been the two non-negotiables of the runbook.

A reusable snippet for multi-site setups

Across the four sites I keep the snippet identical, with the repo name as the only parameter:

# Shared snippet — same shape across all four sites
SITE_REPO="$1"   # e.g. claudelab.net
WORK="/tmp/repos/${SITE_REPO}"
mkdir -p "$WORK" 2>/dev/null || WORK="$HOME/repos/${SITE_REPO}"
mkdir -p "$WORK"
touch "$WORK/.write_test" 2>/dev/null || {
  echo "write test failed even on fallback: $WORK" >&2
  exit 1
}
rm "$WORK/.write_test"
echo "WORK=$WORK"

The trailing .write_test step is what catches the storage-driver class of bugs — the rare case where the kernel reports permission granted but the actual write returns EPERM. I hit one such case on Antigravity Lab and have kept the touch-and-remove pair in place ever since.

Triage checklist

When the schedule does go red, this is the order I work through:

  1. ls -la /tmp/repos/ to see who owns the subtree.
  2. whoami and id to confirm the current execution user.
  3. df -h /tmp and df -h $HOME to compare available capacity on both candidates.
  4. mkdir -p "$WORK" && touch "$WORK/.write_test" && rm "$WORK/.write_test" for a real write test.
  5. If that still fails, switch WORK=$HOME/repos/${SITE} and run the same test again.

Step 4 is the one that earns its keep. A passive test -w would have told me the path was writable in the very session that later failed mid-clone.

Treating $HOME/repos as the source of truth

Because four sites are wired into Cowork-side schedules, the unwritten contract is "do not stop on error, take the next action automatically." Even when the fallback succeeds, I write the chosen path to the log so that task_error.log makes it obvious afterwards which morning's article was generated from home rather than from /tmp.

If you are running a similar unattended pipeline, treating /tmp as a hint rather than a contract is the single change that buys you the most reliability. $HOME/repos lives only as long as the session, but a one-line fallback and a shallow clone are enough to keep yesterday's failure from becoming today's missing post.

Thanks for reading — I hope this is useful for anyone running a parallel indie pipeline of their own.

Share

Thank You for Reading

Claude Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.

  • ✦Copy-paste ready implementation code
  • ✦New advanced guides published daily
  • ✦$5/mo or $10 for lifetime access
View Membership →

If you found this article helpful, a small tip ($1.50) would mean a lot to us. Your support helps keep this site ad-free and covers server and hosting costs.

Related Articles

⟐ Claude Code2026-10-13
The Same Approval Prompt for 'cd, git add, git commit' Every Time — Following It to a Design Choice, Not a Missing Setting
With a compound command like cd /path && git add && git commit, Claude Code keeps asking for approval even with allow rules in place. Reading the official permissions page next to a GitHub report shows that cd combined with git prompts by design. Here is how to tell, in what order to check, and four ways to cut the prompts.
⟐ Claude Code2026-10-12
After Moving to SPM, My App Died the Moment It Launched — Reading One dyld Line with Claude Code
Right after moving from CocoaPods to Swift Package Manager, the build passed but the app died on launch. Here is how I traced one dyld line to its cause, the fix, and how to verify it.
⟐ Claude Code2026-10-05
I Wrote "I'll Do the Commits" in CLAUDE.md. The Next Morning git log Had One More Line.
When Claude Code commits despite being told not to: undo with git reset --soft, then stop it physically with permissions.deny plus a PreToolUse hook. Covers the git -c form that deny rules let through, and how to test the hook without starting Claude Code.