Repository navigation
bug(capability): capability install and remove replace a symlinked settings.json with a plain file #5315
Description
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 serverError 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" serverGSD 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
trek-e commentedon Oct 14, 2026
Diagnosis
Reproduced: Yes, on current
next(scratch HOME):capability install ... --shared-file .claude/settings.jsonwith~/.claude/settings.jsonsymlinked into a dotfiles dir exits 0, leaves a regular file at the link path, and leaves the dotfiles target unchanged.capability removedoes the same (reportsstrippedEdits: 1, link replaced, target untouched).capability updatewas not run; it shares the apply step.Root cause:
writeJsonFileAtomic(src/capability-lifecycle.cts:348, temp + rename) is called byapplyCapabilitySharedEdits(:742) andstripCapabilitySharedEdits(:802).confinedSharedFile(:398-417) realpaths only the parent directory and returns the final component unresolved; nohasExistingSymlinkBetween/isSymlinkedDestOptIncheck 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;stripCapabilitySharedEditsis 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_DESTwith 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-filereplace 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_DESThas 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:
GSD_ALLOW_SYMLINKED_DESTcontract shared with the other install writers.Acceptance criteria:
Out of scope: The first-party settings.json writer (bug(install): symlinked settings.json is replaced with a plain file on every install, ignoring GSD_ALLOW_SYMLINKED_DEST #5037) and Codex config.toml (bug(install): with GSD_ALLOW_SYMLINKED_DEST=1, a symlinked Codex config.toml is replaced with a plain file #5301).
refactor(open-gsd#5037): share the symlinked-file write policy with t…
refactor(open-gsd#5037): share the symlinked-file write policy with t…
refactor(open-gsd#5037): share the symlinked-file write policy with t…