Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 31 additions & 2 deletions claude/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -247,14 +247,21 @@ These keys in `~/.claude/settings.json` are preserved across regenerations:
- `enabledPlugins`: plugin activation state
- `extraKnownMarketplaces`: managed by `configure_marketplaces()` in claudeconfig.sh

## Marketplaces and per-project plugins
## Per-repo project setup

Two scripts stamp settings into an individual repo's `.claude/`, each solving
a different visibility problem: `cloud-project-setup.sh` writes what a _cloud_
session needs to see (so it must be committed), `local-project-setup.sh`
writes what only _this machine_ needs (so it must not be).

### Marketplaces and per-project plugins (cloud)

`marketplaces.jsonc` is the single source of truth for marketplaces (alias ->
GitHub repo) and named plugin `profiles` (`core`, `dev`; default `dev`). Two
consumers read it:

- `claudeconfig.sh` clones the marketplaces globally (`configure_marketplaces()`).
- `claude-project-setup.sh [DIR] [--profile NAME] [--dry-run]` writes a repo's
- `cloud-project-setup.sh [DIR] [--profile NAME] [--dry-run]` writes a repo's
**committed** `.claude/settings.json` (`extraKnownMarketplaces` + `enabledPlugins`)
so Claude Code on the web picks the plugins up. It merges into existing settings
(permissions/hooks survive). See [ADR 0041](../doc/adr/0041-project-level-claude-plugin-bootstrap.md).
Expand All @@ -263,6 +270,28 @@ Plugin keys are `<plugin>@<marketplace-alias>`; the alias is the key in
`marketplaces`, and plugin names must match each repo's
`.claude-plugin/marketplace.json`.

### Cross-repo filesystem access (local)

`cross-repo-access.jsonc` maps a repo name to the sibling repos its sessions
routinely need to read/write directly (e.g. a `pickleclaw` session running
`git`/deploy commands against `picklehome`). The sandbox only auto-grants
write access to a session's own working directory, so without this, those
cross-repo commands fail with `Operation not permitted` and fall back to
`dangerouslyDisableSandbox`.

- `local-project-setup.sh [DIR] [--dry-run]` writes a repo's **gitignored**
`.claude/settings.local.json` (`permissions.additionalDirectories`), listing
each sibling as a directory alongside `DIR` (works under both
`~/github.com/technicalpickles/` and pickled-coi's `~/projects/`). It merges
into existing local settings the same way `cloud-project-setup.sh` merges
into committed settings. See [ADR 0057](../doc/adr/0057-local-cross-repo-filesystem-access.md).

This one is deliberately the opposite of the cloud case: the paths are
machine-specific, so they belong in the gitignored `settings.local.json`, not
the committed `settings.json`. Since it's gitignored, re-run this script on
any other machine (or a fresh clone) where the same repo needs the same
cross-repo access.

## MCP servers

`mcp-servers.jsonc` is the single source of truth for MCP servers registered
Expand Down
20 changes: 20 additions & 0 deletions claude/cross-repo-access.jsonc
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
{
// Single source of truth for which personal repos' Claude Code sessions
// routinely reach into a sibling repo directly (git add/commit/push, deploy
// scripts, etc. run with `cd`/`git -C` against the sibling).
//
// The sandbox only auto-grants write access to a session's own working
// directory (plus its worktree's main-repo .git). A command that `cd`s into
// a different repo entirely gets "Operation not permitted" on that repo's
// .git/index.lock, .git/config, or .git/FETCH_HEAD, and falls back to
// dangerouslyDisableSandbox. See ADR 0057.
//
// Consumed by local-project-setup.sh, which resolves each sibling as a
// directory alongside the target repo and writes it into the target's
// gitignored .claude/settings.local.json as permissions.additionalDirectories.
//
// Keys and values are repo directory names (not paths) -- local-project-setup.sh
// resolves the actual path per machine.
"pickleclaw": ["picklehome", "openclaw-workspace"],
"picklehome": ["pickleclaw"],
}
2 changes: 1 addition & 1 deletion claude/marketplaces.jsonc
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
//
// Consumed by:
// - claudeconfig.sh -> configure_marketplaces() clones these globally
// - claude-project-setup.sh -> writes a repo's committed .claude/settings.json
// - cloud-project-setup.sh -> writes a repo's committed .claude/settings.json
// (extraKnownMarketplaces + enabledPlugins) so
// Claude Code on the web picks the plugins up.
//
Expand Down
4 changes: 2 additions & 2 deletions claudeconfig.sh
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ while [ $# -gt 0 ]; do
done

# read_json (JSONC parser) now lives in functions.sh, shared with
# claude-project-setup.sh.
# cloud-project-setup.sh and local-project-setup.sh.

# Detect role (uses existing DOTPICKLES_ROLE from environment)
ROLE="${DOTPICKLES_ROLE:-home}"
Expand Down Expand Up @@ -578,7 +578,7 @@ configure_marketplaces() {
mkdir -p "$marketplaces_dir"

# Marketplaces come from the shared manifest (single source of truth, also
# read by claude-project-setup.sh). Format per line: "marketplace-id:owner/repo".
# read by cloud-project-setup.sh). Format per line: "marketplace-id:owner/repo".
local manifest="$DIR/claude/marketplaces.jsonc"
if [ ! -f "$manifest" ]; then
echo "Error: $manifest not found"
Expand Down
6 changes: 3 additions & 3 deletions claude-project-setup.sh → cloud-project-setup.sh
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ MANIFEST="$DIR/claude/marketplaces.jsonc"

usage() {
cat << 'EOF'
Usage: claude-project-setup.sh [TARGET_DIR] [--profile NAME] [--list-profiles] [--dry-run]
Usage: cloud-project-setup.sh [TARGET_DIR] [--profile NAME] [--list-profiles] [--dry-run]

Writes <TARGET_DIR>/.claude/settings.json with extraKnownMarketplaces +
enabledPlugins for the chosen profile, MERGING into any existing settings
Expand Down Expand Up @@ -68,7 +68,7 @@ while [ $# -gt 0 ]; do
;;
-*)
echo "Error: unknown option: $1" >&2
echo "Run 'claude-project-setup.sh --help' for usage." >&2
echo "Run 'cloud-project-setup.sh --help' for usage." >&2
exit 2
;;
*)
Expand Down Expand Up @@ -116,7 +116,7 @@ if ! generated="$(echo "$manifest_json" | jq -e --arg profile "$PROFILE" '
}
' 2>&1)"; then
echo "Error: $generated" >&2
echo "Run 'claude-project-setup.sh --list-profiles' to see valid profiles." >&2
echo "Run 'cloud-project-setup.sh --list-profiles' to see valid profiles." >&2
exit 2
fi

Expand Down
6 changes: 6 additions & 0 deletions doc/adr/0041-project-level-claude-plugin-bootstrap.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,12 @@ Date: 2026-06-28

Accepted

Note (2026-09-14): `claude-project-setup.sh` was renamed to
`cloud-project-setup.sh` to distinguish it from the new
`local-project-setup.sh` (see [ADR 0057](0057-local-cross-repo-filesystem-access.md)),
which solves an unrelated, machine-local problem. The decision below is
unchanged; only the filename is.

## Context

Claude Code on the web (cloud) clones a repo fresh into an ephemeral container
Expand Down
132 changes: 132 additions & 0 deletions doc/adr/0057-local-cross-repo-filesystem-access.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
# 57. Local cross-repo filesystem access

Date: 2026-09-14

## Status

Accepted

## Context

A `cq` audit of unsandboxed Bash commands over a week (2026-09-07 to
2026-09-14) found 671 calls run with `dangerouslyDisableSandbox`, 144 of them
git-related. The single largest identifiable cause (~60 of the 144, across
sessions in `pickleclaw`, `picklehome`, and `openclaw-workspace`) was a
session rooted in one personal repo running a git command against a
_different_ sibling repo -- e.g. a `pickleclaw` session doing
`cd ~/github.com/technicalpickles/picklehome && git commit ...` to land a
homelab config change, or a `picklehome` bridge worktree merging back into
`pickleclaw`.

The sandbox's default write grant covers only a session's own working
directory (plus, for a linked git worktree, its main repo's shared `.git` --
built into Claude Code itself, no config needed). A command that `cd`s or
`git -C`s into an unrelated repo entirely falls outside both, and fails with
`Operation not permitted` on that repo's `.git/index.lock`, `.git/config`, or
`.git/FETCH_HEAD`. The retry is `dangerouslyDisableSandbox`, every time,
forever, because nothing about the failure is transient.

This is a different problem from the one [ADR 0041](0041-project-level-claude-plugin-bootstrap.md)
solved. That ADR's `cloud-project-setup.sh` stamps a repo's **committed**
`.claude/settings.json` with plugin config, because a cloud session clones a
repo fresh with no global `~/.claude` and can only see what's checked in.
Cross-repo filesystem access is the opposite: the correct path
(`~/github.com/technicalpickles/picklehome` on this Mac,
`~/projects/picklehome` on the `pickled-coi` VM) is inherently
machine-specific. Checking an absolute path like that into a repo's committed
settings would be silently wrong wherever the layout differs -- exactly the
failure mode ADR 0041 rejected `settings.local.json` to avoid, just pointed
the other direction.

## Decision

Add `claude/cross-repo-access.jsonc`, a manifest mapping a repo name to the
sibling repos its sessions are known to reach into directly (currently
`pickleclaw` -> `[picklehome, openclaw-workspace]`, `picklehome` ->
`[pickleclaw]`, derived from the `cq` audit).

Add `local-project-setup.sh [TARGET_DIR] [--dry-run]`, mirroring
`cloud-project-setup.sh`'s shape (manifest-driven, `jq` merge preserving
other keys, atomic write + `jq empty` validation, one-time `.backup`) but
writing `permissions.additionalDirectories` into `TARGET_DIR`'s **gitignored**
`.claude/settings.local.json` instead. Each declared sibling resolves to a
directory alongside `TARGET_DIR`, so the same manifest entry produces the
right path under any parent directory convention without role-detection
logic. `additionalDirectories` (not `sandbox.filesystem.allowWrite`) is the
right primitive here: it grants ordinary read+write to the whole sibling
tree, matching what a command doing real work there needs, rather than
chasing individual `.git/*` paths one at a time.

Renamed `claude-project-setup.sh` to `cloud-project-setup.sh` to make the
split legible: `cloud-project-setup.sh` writes what a cloud session needs to
see (must be committed), `local-project-setup.sh` writes what only this
machine needs (must not be committed). See the note added to ADR 0041.

Because `settings.local.json` is gitignored, this manifest only takes effect
once `local-project-setup.sh` is run against each affected repo on each
machine -- it does not propagate through `claudeconfig.sh` or `git pull`. It
is deliberately not wired into `claudeconfig.sh`'s automatic run, matching
`cloud-project-setup.sh`'s existing manual-per-repo pattern (ADR 0041): the
set of repos that need this is small and known, not every clone.

### Alternatives Considered

1. **Wildcard `sandbox.filesystem.allowWrite` for all personal repos**
(`~/github.com/technicalpickles/*/.git` or broader) in the global `home`
role.

- Pros: covers any future repo automatically, no manifest to maintain.
- Cons: removes isolation between every personal repo for every session,
not just the specific pairs that actually cross-reference each other.
Widens `allowWrite`, which per the sandboxing docs is meant for narrow
tool-state paths, not general read+write to whole repos.
- Rejected: blast radius too broad for the actual, small set of repos
involved.

2. **Narrow `allowWrite` entries for the specific failing paths**
(`.git/index.lock`, `.git/config`, `.git/FETCH_HEAD`,
`.git/worktrees/*/index.lock`).

- Pros: minimal new write access.
- Cons: whack-a-mole -- any new cross-repo operation (reading a file,
running a script in the sibling repo, `just deploy-*`) hits a path not
on the list. `additionalDirectories` already exists as the primitive for
"treat this directory like a working directory."
- Rejected: solves the symptom, not the shape of the actual workflow.

3. **Global `home` role entry, same as the SSH relay fix**
(`claude/roles/home.jsonc`).
- Pros: one place, consistent with how the SSH-over-relay fix was applied.
- Cons: `home.jsonc` is user-scope and applies to _every_ project
regardless of whether it has anything to do with `picklehome` or
`pickleclaw`. The access is a property of a specific pair of repos, not
of the role.
- Rejected: wrong scope; per-repo project settings is what
`additionalDirectories` and workspace trust are designed around.

## Consequences

### Positive

- Removes the largest single identified cause of `dangerouslyDisableSandbox`
git usage (per the `cq` audit), without widening sandbox access beyond the
specific repo pairs that need it.
- `claude/cross-repo-access.jsonc` is one place to see (and extend) which
repos are known to cross-reference each other.
- Consistent with the existing `cloud-project-setup.sh` pattern; the rename
makes the cloud/local split self-explanatory instead of needing this ADR
read first.

### Negative

- Gitignored means it does not propagate: a fresh clone or a new machine
needs `local-project-setup.sh` re-run by hand. No automation runs it
today.
- The manifest can drift from reality -- a new cross-repo workflow needs a
manual addition, the same maintenance cost `marketplaces.jsonc` already
has.
- Bootstrapping `local-project-setup.sh` itself needs
`dangerouslyDisableSandbox` when run from a session not rooted in
`TARGET_DIR` (e.g. a dotfiles session writing into `pickleclaw`'s
settings), since that write is itself a cross-repo write. Run it from
inside `TARGET_DIR` to avoid that.
1 change: 1 addition & 0 deletions doc/adr/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,3 +55,4 @@
- [54. sync-authorized-keys-from-1password-for-inbound-ssh](0054-sync-authorized-keys-from-1password-for-inbound-ssh.md)
- [55. agent-git-over-ssh-through-the-relay](0055-agent-git-over-ssh-through-the-relay.md)
- [56. auto-mode-classifier-rules-in-role-sources](0056-auto-mode-classifier-rules-in-role-sources.md)
- [57. local-cross-repo-filesystem-access](0057-local-cross-repo-filesystem-access.md)
2 changes: 1 addition & 1 deletion doc/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ The central architectural pattern is **role-based adaptation**. The role is dete

The canonical role values are `home`, `work`, `container`, and `claude-code-remote` (see [ADR 0035](adr/0035-canonical-dotpickles-role-names.md) and [ADR 0040](adr/0040-claude-code-remote-role.md)). Detection precedence: `claude-code-remote` when `CLAUDE_CODE_REMOTE=true` (Claude Code on the web; cloud is also a container, so this must win), then `container` inside containers, then `work` for hostnames matching `josh-nichols-*`, otherwise `home`. The same check is duplicated across bash, fish, and zsh because they can't share one snippet: bash scripts get it from `dotpickles_detect_role` in [functions.sh](../functions.sh) (sourced by [install.sh](../install.sh) and standalone setup scripts), while the interactive shells set it themselves in [config/fish/conf.d/dotpickles-role.fish](../config/fish/conf.d/dotpickles-role.fish) and [home/.zshenv](../home/.zshenv). The canonical-name list and a fail-loud guard ([ADR 0036](adr/0036-fail-loud-role-resolution.md)) keep the copies from drifting silently. The fish copy lives in `conf.d/` (not `config.fish`) on purpose: fish sources `conf.d/*.fish` before `config.fish`, and the starship prompt reads `DOTPICKLES_ROLE` at init time, so the role must be set first.

In a Claude Code cloud session, the dotfiles entry point is [claudeconfig.sh](../claudeconfig.sh) on its own (set it as the environment's setup command), not the full [install.sh](../install.sh). The `claude-code-remote` role makes that run lean: the sandbox is off (the cloud container is already isolated), there is no agent SSH identity (git goes through the GitHub integration), and macOS-only bits are inert. Per-repo plugins are a separate mechanism: [claude-project-setup.sh](../claude-project-setup.sh) stamps a committed `.claude/settings.json` (`extraKnownMarketplaces` + `enabledPlugins`) from the shared [claude/marketplaces.jsonc](../claude/marketplaces.jsonc) manifest, since cloud only reads a repo's committed settings (see [ADR 0041](adr/0041-project-level-claude-plugin-bootstrap.md)).
In a Claude Code cloud session, the dotfiles entry point is [claudeconfig.sh](../claudeconfig.sh) on its own (set it as the environment's setup command), not the full [install.sh](../install.sh). The `claude-code-remote` role makes that run lean: the sandbox is off (the cloud container is already isolated), there is no agent SSH identity (git goes through the GitHub integration), and macOS-only bits are inert. Per-repo plugins are a separate mechanism: [cloud-project-setup.sh](../cloud-project-setup.sh) stamps a committed `.claude/settings.json` (`extraKnownMarketplaces` + `enabledPlugins`) from the shared [claude/marketplaces.jsonc](../claude/marketplaces.jsonc) manifest, since cloud only reads a repo's committed settings (see [ADR 0041](adr/0041-project-level-claude-plugin-bootstrap.md)). Its local counterpart, [local-project-setup.sh](../local-project-setup.sh), does the opposite: it stamps a repo's gitignored `.claude/settings.local.json` with `permissions.additionalDirectories` from the [claude/cross-repo-access.jsonc](../claude/cross-repo-access.jsonc) manifest, so a session rooted in one personal repo can read/write a sibling repo it routinely reaches into (e.g. `pickleclaw` deploying into `picklehome`) without the sandbox blocking it (see ADR 0057).

## Symlink-Based File Management

Expand Down
2 changes: 1 addition & 1 deletion functions.sh
Original file line number Diff line number Diff line change
Expand Up @@ -256,7 +256,7 @@ dotpickles_detect_role() {
dotpickles_detect_role

# Read a JSON or JSONC file to stdout, stripping comments and trailing commas.
# Shared by claudeconfig.sh and claude-project-setup.sh.
# Shared by claudeconfig.sh, cloud-project-setup.sh, and local-project-setup.sh.
#
# Uses a single python3 parser rather than the node path it once had. The node
# path stripped comments with regexes that were not string-aware, so a glob like
Expand Down
Loading
Loading