From afc34d87643bfec662f5bd65563b7d29969e367b Mon Sep 17 00:00:00 2001 From: Roberto Cano <3525807+robercano@users.noreply.github.com> Date: Wed, 1 Jul 2026 20:50:59 +0200 Subject: [PATCH] chore(harden): commit /harden command + doc, gitignore/staging fixes for sandbox masks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add .claude/commands/harden.md (was present locally but never committed). - docs/HARDENING.md: document that a sandboxed session masks sensitive config paths as /dev/null device nodes (phantom git-status noise), and how to cope. - .gitignore: ignore machine-local settings.local.json + the unambiguous personal dotfiles the sandbox masks (NOT .mcp.json/.gitmodules/.claude/* — those can be real). - implementer.md / orchestrator.md: hard rule to stage explicit paths, never `git add -A`/`git commit -a`, so a masked device node can't abort a worker's commit. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_011HosUeuSvhetARboEfDW6K --- .claude/agents/implementer.md | 5 ++++ .claude/agents/orchestrator.md | 1 + .claude/commands/harden.md | 49 ++++++++++++++++++++++++++++++++++ .gitignore | 21 ++++++++++++++- docs/HARDENING.md | 29 ++++++++++++++++++++ 5 files changed, 104 insertions(+), 1 deletion(-) create mode 100644 .claude/commands/harden.md diff --git a/.claude/agents/implementer.md b/.claude/agents/implementer.md index c1b3d4c..c55ee1a 100644 --- a/.claude/agents/implementer.md +++ b/.claude/agents/implementer.md @@ -19,6 +19,11 @@ Never call bare `gh`. EVERY `gh` invocation (PR create/update, comments, `gh api 1. **Explore, don't guess.** Delegate codebase discovery to the `Explore` subagent to map the files you'll touch. Stay read-only until you understand the area. 2. **Respect your boundary.** You were assigned a module/path. NEVER edit files outside it. If the task truly requires touching another module, stop and report back to the orchestrator — do not reach across the boundary. 3. **Implement in small commits.** Match surrounding code style. Write/extend tests alongside the change. + **Stage explicit paths only — never `git add -A` / `git add .` / `git commit -a`.** A sandboxed session + masks sensitive config paths (shell rc, `.gitconfig`, `.mcp.json`, `.claude/{hooks,skills,routines}`, + editor dirs) as `/dev/null` character-device nodes; `git status` shows them as untracked, and a blanket + `git add` can try to index a device node and abort your commit. Add the files you actually changed, by + name. Ignore any `crw-` device-node entries `git status` shows — they are sandbox masks, not your work. 4. **Self-gate before declaring done.** Run, in order, the commands from `.claude/gates.json`: `build` → `lint` → `typecheck` → `test_affected` → `coverage`. Use `.claude/scripts/gate.sh ` if present. Fix anything that fails. Do not report done with a red gate. 5. **Open a PR** (or leave the branch ready, per `CLAUDE.md` merge policy). 6. **Report back** in this format: diff --git a/.claude/agents/orchestrator.md b/.claude/agents/orchestrator.md index 7831e35..c478e2d 100644 --- a/.claude/agents/orchestrator.md +++ b/.claude/agents/orchestrator.md @@ -28,6 +28,7 @@ EVERY `gh` invocation — by you and by every agent you spawn — MUST go throug - Give every worker a crisp objective, an explicit file/module boundary, an output format, and the exact gate commands. Vague delegation produces overlap and rework. - Never spawn more than `max_parallel_workers` at once. - Keep your own context clean: delegate exploration to the `Explore` subagent (read-only, cheap), not yourself. +- Git hygiene: tell workers to **stage explicit paths, never `git add -A`/`git commit -a`**. A sandboxed session masks config paths (shell rc, `.gitconfig`, `.mcp.json`, `.claude/{hooks,skills,routines}`, editor dirs) as `/dev/null` device nodes that show up in `git status`; a blanket add can abort the commit. They're expected artifacts, not the worker's changes (see `docs/HARDENING.md` → Caveats). ## Status report format (your "standup") ``` diff --git a/.claude/commands/harden.md b/.claude/commands/harden.md new file mode 100644 index 0000000..eba0ad7 --- /dev/null +++ b/.claude/commands/harden.md @@ -0,0 +1,49 @@ +--- +description: Arm hands-off mode — write the bypass + strict-sandbox profile into settings.local.json (per docs/HARDENING.md) +--- + +You are arming this project for **hands-off (autonomous) operation**: `bypassPermissions` + a strict OS +sandbox + an always-on deny list, so the PR loop can run without a human approving every command. +`docs/HARDENING.md` is the source of truth — this command materializes its **Step 1** config and then +tells the human which steps only they can finish (sudo / OS-level / restart). + +> Run this **before** hardening (while still in the safe interactive mode). Once the hardened profile is +> active, the agent can no longer edit `.claude/settings*.json` (it's in the Edit/Write fence) — by design. + +Do these in order. Stop and report if any step can't be completed. + +## 1. Preconditions +- Read `docs/HARDENING.md` — its Step 1 JSON block is the canonical config; use it verbatim as the base. +- Confirm the sandbox backend: `command -v bwrap socat`. If either is MISSING, do NOT enable strict mode + blindly — report it and point the user at HARDENING.md Step 2 (`sudo apt-get install -y bubblewrap socat`). + You can still write the file, but warn that `failIfUnavailable: true` will refuse to start until they're installed. + +## 2. Ensure `.claude/settings.local.json` is gitignored +- Check `git check-ignore .claude/settings.local.json`. If NOT ignored, append `.claude/settings.local.json` + to `.gitignore`. Bypass is a per-machine decision and must never be inherited by a clone. + +## 3. Write / merge the hardened profile into `.claude/settings.local.json` +- Materialize the **Step 1 block from `docs/HARDENING.md`**: `permissions.defaultMode = "bypassPermissions"`, + the full `permissions.deny` list (privilege escalation, container escape, raw-network/exfil, publish, + `gh auth token`/`secret`, force-push/`rm -rf`, secret-file Reads, WSL host interop, and the portable + Edit/Write fence), and the strict `sandbox` block (`enabled: true`, `allowUnsandboxedCommands: false`, + `failIfUnavailable: true`, the `denyRead`/`credentials` lists). +- **Merge, don't clobber:** if `settings.local.json` already exists, preserve any existing + `permissions.allow` entries and merge the deny list (union, no dupes). Keep the result valid JSON. +- Do **not** touch the committed `.claude/settings.json` — it stays at the safe interactive default, and + the harness owns it at runtime. +- Honor the `.env` gotcha: `.env` is denied to the **Read tool** but must **not** be added to + `sandbox.credentials`/`denyRead`, or the loop scripts can't source `GH_BOT_TOKEN`. + +## 4. Report what only the human can finish +Print a short checklist of the steps this command cannot do (they need a real terminal / sudo / restart): +- **Restart Claude Code** — bypass + sandbox changes don't hot-reload, and bypass needs the dangerous-mode + dialog accepted once. Then run `/sandbox` to confirm the backend resolves. +- **OS-level isolation (HARDENING.md Step 2)** — cut host bridges (on WSL: `/etc/wsl.conf` automount+interop + off), drop the agent user from `docker`/`sudo` groups, prefer a dedicated disposable distro/VM. +- **Verify the backstop bites:** after restart, ask the agent to run `sudo true` (should be **blocked** + under bypass) and a write outside the repo (should **fail**, proving strict mode). +- Optional **managed settings (Step 4)** to make the policy un-overridable, and a **token budget** for + unattended runs (see `docs/TOKEN_BUDGET.md`). + +Finish with one line: the path written, whether the backend is present, and "restart required". diff --git a/.gitignore b/.gitignore index 6697cc6..19cef86 100644 --- a/.gitignore +++ b/.gitignore @@ -4,4 +4,23 @@ # Secrets — bot token (GH_BOT_TOKEN) and any local env. Commit .env.example only. .env .env.* -!.env.example \ No newline at end of file +!.env.example + +# Machine-local hardened profile (bypass + sandbox) — never inherited by a clone. +.claude/settings.local.json + +# Personal / editor config that the OS sandbox masks with /dev/null device nodes, +# so it surfaces as bogus "untracked" entries in a sandboxed session (see +# docs/HARDENING.md → Caveats). Never part of the project. NOTE: intentionally does +# NOT include .mcp.json, .gitmodules, or .claude/{hooks,skills,routines,launch.json} +# — those can be legitimate tracked project files; the explicit-staging rule in +# .claude/agents/implementer.md protects them instead. +.bashrc +.bash_profile +.zshrc +.zprofile +.profile +.gitconfig +.ripgreprc +.idea/ +.vscode/ \ No newline at end of file diff --git a/docs/HARDENING.md b/docs/HARDENING.md index b086a5e..4cf00dc 100644 --- a/docs/HARDENING.md +++ b/docs/HARDENING.md @@ -248,6 +248,19 @@ command -v cmd.exe # should print nothing sudo gpasswd -d "$USER" docker # if you don't need Docker in this distro ``` +> **Gotcha — cutting `/mnt` breaks any tool pinned to a Windows binary.** WSL+Windows setups often +> point Linux tools at Windows executables under `/mnt/c`. Once `/mnt` is gone those fail with +> `cannot run /mnt/c/.../foo.exe: No such file or directory`. The common one is **git's SSH**: a global +> `core.sshCommand = /mnt/c/Windows/System32/OpenSSH/ssh.exe` (and often a `~/.zshrc`/`~/.bashrc` +> `alias ssh=...exe`) makes `git push` fail. Fix — switch to the Linux toolchain: +> ```bash +> sudo apt-get install -y openssh-client # provides /usr/bin/ssh +> git config --global --unset core.sshCommand # fall back to the Linux ssh on PATH +> # then either add a Linux SSH key to GitHub, or move the remote to https + `gh auth setup-git` +> ``` +> Also scrub any `alias ssh=/mnt/...exe` from your shell rc. Same applies to editors, credential +> helpers, or `GIT_*` vars pointed at `.exe` paths. + --- ## Step 3 — Activate and verify @@ -320,6 +333,22 @@ agent operates *inside*, not one it configures. - **Worktrees outside the repo** aren't writable under the sandbox. Under strict mode (`allowUnsandboxedCommands: false`, recommended) such a write *fails* rather than escaping — so keep worktree paths inside the repo, or allowlist the specific command via `excludedCommands`. +- **In-session commands are sandboxed too — including the `!` prefix.** Anything Claude Code runs, + whether a tool call or a command you type with the `!` prefix, runs inside the Bash sandbox (writes + confined to repo + `$TMPDIR`). Under strict mode that means **host/home config changes fail even when + you type them yourself** — `git config --global` (`~/.gitconfig`), editing `~/.zshrc`, `ssh-keygen` + (`~/.ssh`), `gh auth` (`~/.config/gh`), etc. all error with `Read-only file system`. Run those in a + **real terminal outside Claude Code**. (This is the containment working as intended, not a bug — but + it surprises people the first time.) +- **Sandboxed sessions mask config paths as `/dev/null` — expect phantom `git status` noise.** The + sandbox bind-mounts `/dev/null` over sensitive paths it won't let the agent read (shell rc, `.gitconfig`, + editor dirs, `.mcp.json`, and Claude's own `.claude/{hooks,skills,routines,launch.json}`). In a + sandboxed view these appear as **character-device files** (`ls -l` shows `crw-rw-rw- … 1, 3`), which + `git status` reports as untracked/modified even though they aren't real project files. This is expected, + not corruption. Two consequences: (1) **never `git add -A` / `git commit -a`** — git can't index a + device node and the commit may abort; stage explicit paths instead (the agent instructions enforce this). + (2) The unambiguous personal dotfiles are gitignored so they don't surface; `.mcp.json`/`.gitmodules`/ + `.claude/*` are deliberately *not* ignored (they can be real), so rely on explicit staging there. - **Open PRs gate loop advancement.** A typical loop won't start a new ticket while a PR is open — that's by design (it keeps you the merge gate). Review/merge to let it advance. - **The committed `settings.json` is owned by the harness at runtime** (it may rewrite the working-tree