Skip to content

bug(capability): capability install and remove replace a symlinked settings.json with a plain file #5315

Open

Description

@runnithan

Pre-submission checklist

  • I have searched existing issues and this bug has not already been reported

GSD Version

1.16.0

Runtime

Claude Code

Operating System

Linux (Ubuntu/Debian)

Node.js Version

v24.15.0

Shell

bash

Installation Method

Updated from a previous version

What happened?

When a capability that adds hooks or MCP servers is installed with --shared-file .claude/settings.json, and ~/.claude/settings.json is a symlink (a dotfiles layout), gsd capability install replaces the link with a plain file. The capability's entries go into the new plain file, the link's real target is left unchanged, and the command exits 0 with no warning. GSD_ALLOW_SYMLINKED_DEST makes no difference: the result is the same with and without it.

gsd capability remove does the same: it replaces the link with a plain file without the capability's entries, and the link's target keeps them, so the dotfiles copy is left with an MCP server entry for a capability that is gone.

Cause: applyCapabilitySharedEdits and stripCapabilitySharedEdits (src/capability-lifecycle.cts) save through writeJsonFileAtomic, which calls platformWriteSync (src/shell-command-projection.cts). That writes <file>.tmp.<pid> and renames it onto the path, and a rename onto a symlink replaces the link. confinedSharedFile resolves the parent directory's real path (see #1460, CONF-2) but returns path.join(realParent, path.basename(target)), so a symlinked file itself passes through unresolved, and no hasExistingSymlinkBetween / isSymlinkedDestOptIn check runs before the write.

Same mechanism as #5037 (writeSettings, fix in #5300) and #5301 (Codex config.toml), on the capability lifecycle's write path.

What did you expect?

The GSD_ALLOW_SYMLINKED_DEST contract the other install writers follow: without the opt-in, refuse to write a symlinked shared file and name the opt-in, leaving the link alone; with it, write through to the link's target so the link stays a link.

Steps to reproduce

Run from a gsd-core checkout; everything happens in a temp HOME.

G=$PWD; T=$(mktemp -d); H="$T/home"
mkdir -p "$H/.claude" "$T/dotfiles" "$T/cap" "$T/cwd/.planning"; echo '{}' > "$T/cwd/.planning/config.json"
printf '{"model":"keep-me"}\n' > "$T/dotfiles/settings.json"
ln -s "$T/dotfiles/settings.json" "$H/.claude/settings.json"
cat > "$T/cap/capability.json" <<'JSON'
{"id":"acme-demo","role":"feature","version":"1.0.0","title":"acme-demo","description":"repro","tier":"standard",
 "requires":[],"runtimeCompat":{"supported":["*"],"unsupported":[]},"skills":[],"agents":[],"hooks":[],
 "config":{},"steps":[],"contributions":[],"gates":[],"mcpServers":{"demo":{"command":"echo"}}}
JSON
cd "$T/cwd"
env -u GSD_ALLOW_SYMLINKED_DEST -u GSD_WORKSTREAM -u GSD_PROJECT GSD_HOME="$H" HOME="$H" \
  node "$G/gsd-core/bin/gsd-tools.cjs" capability install "$T/cap" --scope global --yes --shared-file .claude/settings.json --raw >/dev/null; echo "exit=$?"
stat -c %F "$H/.claude/settings.json"   # expected: symbolic link
cat "$T/dotfiles/settings.json"          # expected: includes the "demo" MCP server

Error output / logs

No error: every run exits 0 with nothing on stderr. Observed on `next` at b747aca5b:


install, GSD_ALLOW_SYMLINKED_DEST unset: exit=0, settings.json becomes a regular file holding the "demo" server, dotfiles copy unchanged
install, GSD_ALLOW_SYMLINKED_DEST=1:     exit=0, same result
remove (link re-created first):          exit=0, settings.json becomes a regular file without the server, dotfiles copy still has the "demo" server

GSD Configuration

GSD State (if relevant)

Runtime settings.json (if relevant)

How often does this happen?

Every time (100% reproducible)

Impact

Moderate — Feature is broken but I have a workaround

Workaround (if any)

Re-create the symlink after each gsd capability install, update or remove that names a shared file, and copy the change into the link's target by hand.

Additional context

Found while working on #5037. Only the capability management commands take this path (install / update / remove with --shared-file); normal installs and /gsd-update do not. Reproduced with gsd-tools.cjs from a local checkout of next; capability update goes through the same apply step but was not run. #1460's CONF-2 (fixed in #1481) covered a symlinked parent folder of the shared file, not a symlinked file itself.

Privacy Checklist

  • I have reviewed all pasted output for PII (usernames, paths, API keys) and redacted where necessary

Activity

  1. added
    bugSomething isn't working
    needs-triageNew issue awaiting maintainer review
    on Oct 11, 2026
  2. trek-e commented on Oct 14, 2026

    @trek-e
    Collaborator

    This was generated by AI during triage.

    Diagnosis

    Reproduced: Yes, on current next (scratch HOME): capability install ... --shared-file .claude/settings.json with ~/.claude/settings.json symlinked into a dotfiles dir exits 0, leaves a regular file at the link path, and leaves the dotfiles target unchanged. capability remove does the same (reports strippedEdits: 1, link replaced, target untouched). capability update was not run; it shares the apply step.
    Root cause: writeJsonFileAtomic (src/capability-lifecycle.cts:348, temp + rename) is called by applyCapabilitySharedEdits (:742) and stripCapabilitySharedEdits (:802). confinedSharedFile (:398-417) realpaths only the parent directory and returns the final component unresolved; no hasExistingSymlinkBetween / isSymlinkedDestOptIn check runs before the write.
    Introduced by: Long-standing (9219af3 2026-06-18; 34bc096 2026-06-19). #1460 CONF-2 covered a symlinked parent only.
    Blast radius: Low. 2 callers of writeJsonFileAtomic; stripCapabilitySharedEdits is reached from remove, reapply (install/update) and reconcile. No existing tests found within 3 caller hops.
    Coupled: Install, update and reconcile all go through these two functions, so fix at that seam. Today an unsafe shared path is skipped silently; the refusal for a symlinked file must be loud and name the opt-in. Temp file must sit beside the real target. Same mechanism as #5037 / #5301.
    Regression test: Install with a symlinked shared file: opt-in unset fails naming GSD_ALLOW_SYMLINKED_DEST with link and target untouched; opt-in set keeps the link and puts the entries in the target; remove strips the entries from the target and keeps the link.
    Memtrace was unavailable this run; the evidence is CodeGraph + git history + a scratch repro.

    Agent Brief

    Category: bug
    Summary: Capability install/update/remove with --shared-file replace a symlinked shared settings file with a plain file.
    Current behavior: Entries are written to a new regular file in place of the link; the link's target is unchanged; the command exits 0 with no warning; GSD_ALLOW_SYMLINKED_DEST has no effect.
    Desired behavior: Without the opt-in, refuse to write a symlinked shared file, name the opt-in, and leave the link alone. With it, write through so the link stays a link and the target holds the change (including entry removal on remove).
    Key interfaces:

  3. added and removed
    needs-triageNew issue awaiting maintainer review
    on Oct 14, 2026
  4. added 3 commits that reference this issue on Oct 15, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingconfirmed-bugVerified reproducible bug

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions