Skip to content

Commit 4598b69

Browse files
committed
Tighten config-dir perms and validate plugin scope argv
- Create the global config dir at 0700 (it can hold credentials.json) in the skill-refresh and update-notice bootstrap paths, and chmod it once early in root.go so an existing 0755 dir from an older version is tightened. The chmod Lstats first and only touches a real directory (never a symlink), since GlobalConfigDir can fall back to TempDir where a local user could plant one. - Whitelist the plugin --scope value {user,project,local,global} read from installed_plugins.json before passing it to 'claude plugin uninstall', preventing a '-'-leading value from injecting a flag into the argv.
1 parent 97fd65d commit 4598b69

8 files changed

Lines changed: 461 additions & 2 deletions

File tree

internal/cli/owner_unix.go

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
//go:build !windows
2+
3+
package cli
4+
5+
import (
6+
"os"
7+
"syscall"
8+
)
9+
10+
// isForeignOwnerWritable reports whether fi is owner-writable and owned by a
11+
// user other than the current effective user and other than root. Such an
12+
// ancestor lets its owner substitute a path component and win the Lstat->Chmod
13+
// TOCTOU race. Root-owned dirs are trusted (root already has full access);
14+
// self-owned dirs are under our control.
15+
func isForeignOwnerWritable(fi os.FileInfo) bool {
16+
st, ok := fi.Sys().(*syscall.Stat_t)
17+
if !ok {
18+
return false
19+
}
20+
return fi.Mode()&0o200 != 0 && st.Uid != uint32(os.Geteuid()) && st.Uid != 0 //nolint:gosec // G115: Geteuid() returns a valid non-negative uid that fits in uint32
21+
}

internal/cli/owner_windows.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
//go:build windows
2+
3+
package cli
4+
5+
import "os"
6+
7+
// isForeignOwnerWritable is a no-op on Windows, where the Unix owner model and
8+
// this TOCTOU vector do not apply; the group/world-writable check still runs.
9+
func isForeignOwnerWritable(os.FileInfo) bool { return false }

internal/cli/root.go

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,33 @@ func NewRootCmd() *cobra.Command {
4444
return nil
4545
}
4646

47+
// Tighten global config dir perms: an older CLI version (or the
48+
// skill/update bootstrap) may have created it world-listable at
49+
// 0755, yet it can hold credentials.json. Best-effort, runs before
50+
// anything writes into the dir.
51+
//
52+
// Lstat first and only chmod a real directory: os.Chmod follows
53+
// symlinks, and GlobalConfigDir() can fall back to TempDir(), so a
54+
// local attacker could plant a symlink there and redirect the chmod.
55+
if cfgDir := config.GlobalConfigDir(); cfgDir != "" {
56+
// Skip the chmod when any ancestor of cfgDir is world- or
57+
// group-writable (or can't be stat'd): a local user with write
58+
// access to any path component could swap a component for a symlink
59+
// between our Lstat and Chmod, making the pair racy (Lstat→Chmod
60+
// TOCTOU). Checking only the immediate parent is insufficient — a
61+
// world-writable ancestor higher up (e.g. /shared above a 0755
62+
// /shared/.config) lets the same substitution win the race. Group
63+
// members with write access can win it too, so guard on 0o022 (group
64+
// + world write), not just the world-writable bit. This covers both
65+
// the os.TempDir() fallback and XDG_CONFIG_HOME=/tmp. We only harden
66+
// the dir when no ancestor is attacker-writable.
67+
if !hasWritableAncestor(cfgDir) {
68+
if fi, lstatErr := os.Lstat(cfgDir); lstatErr == nil && fi.IsDir() && fi.Mode()&os.ModeSymlink == 0 {
69+
_ = os.Chmod(cfgDir, 0o700) //nolint:gosec // G302: 0700 is correct for a directory (needs the execute bit) that can hold credentials.json
70+
}
71+
}
72+
}
73+
4774
// Start background update check early so it runs during command execution
4875
updateCheck = commands.StartUpdateCheck()
4976

@@ -270,6 +297,61 @@ func NewRootCmd() *cobra.Command {
270297
return cmd
271298
}
272299

300+
// hasWritableChain reports whether any ancestor of path (its parent up to the
301+
// filesystem root) is group- or world-writable, owned by a foreign non-root user
302+
// with the owner-write bit set, or cannot be stat'd. Each ancestor directory is
303+
// stat'd directly, so a world-writable dir that merely contains a symlink
304+
// component (e.g. /tmp holding /tmp/link) is still caught. The foreign-owner
305+
// check catches an ancestor owned by a DIFFERENT non-root local user (e.g. mode
306+
// 0755 owned by another account): its owner can still substitute a path component
307+
// and win the race even though no group/world-write bit is set. Root-owned and
308+
// self-owned ancestors are trusted.
309+
func hasWritableChain(path string) bool {
310+
dir := filepath.Dir(path)
311+
for {
312+
fi, err := os.Stat(dir)
313+
if err != nil || fi.Mode()&0o022 != 0 || isForeignOwnerWritable(fi) {
314+
return true
315+
}
316+
parent := filepath.Dir(dir)
317+
if parent == dir { // reached root
318+
return false
319+
}
320+
dir = parent
321+
}
322+
}
323+
324+
// hasWritableAncestor reports whether the best-effort chmod of path would be unsafe:
325+
// the path is non-absolute, or any ancestor of EITHER the original (lexical) path OR
326+
// the symlink-resolved real path is group- or world-writable, owned by a foreign
327+
// non-root user with the owner-write bit set, or can't be stat'd. Such an ancestor
328+
// lets another user substitute a path component and win the Lstat->Chmod TOCTOU
329+
// race, so the chmod is skipped in that case. The foreign-owner case covers an
330+
// ancestor that is not group/world-writable yet is owned by a DIFFERENT non-root
331+
// local account whose owner-write bit lets that owner perform the substitution.
332+
//
333+
// Both chains must be clean. Walking only the resolved chain misses a writable dir
334+
// that holds a symlink component: EvalSymlinks jumps to the symlink target and skips
335+
// the writable dir entirely (e.g. XDG_CONFIG_HOME=/tmp/link pointing into a private
336+
// 0755 tree leaves the world-writable /tmp unexamined), which a local user can swap
337+
// between our Lstat and Chmod. Walking only the lexical chain misses a writable real
338+
// ancestor reached through a symlinked component. A relative path can't be reasoned
339+
// about reliably (its real ancestry depends on cwd), and an unresolvable/missing
340+
// component means EvalSymlinks fails; both are treated conservatively as unsafe.
341+
func hasWritableAncestor(path string) bool {
342+
if !filepath.IsAbs(path) {
343+
return true // can't reason about a relative config dir; treat as unsafe
344+
}
345+
if hasWritableChain(path) { // lexical ancestors of the original path
346+
return true
347+
}
348+
resolved, err := filepath.EvalSymlinks(path)
349+
if err != nil {
350+
return true // unresolvable/missing component => be conservative
351+
}
352+
return hasWritableChain(resolved) // ancestors of the real path
353+
}
354+
273355
// Execute runs the root command.
274356
func Execute() {
275357
cmd := NewRootCmd()

internal/cli/root_test.go

Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ package cli
22

33
import (
44
"bytes"
5+
"os"
6+
"path/filepath"
57
"testing"
68

79
"github.com/spf13/cobra"
@@ -59,6 +61,174 @@ func TestLLMEndpointValidation(t *testing.T) {
5961
}
6062
}
6163

64+
// TestHasWritableAncestor exercises the TOCTOU guard that gates the best-effort
65+
// chmod of the global config dir in PersistentPreRunE. The chmod is skipped when
66+
// any ancestor — not just the immediate parent — is group/world-writable, since a
67+
// writable ancestor anywhere up the path lets an attacker substitute a path
68+
// component and win the Lstat->Chmod race.
69+
//
70+
// hasWritableAncestor walks the filesystem all the way to root, so a clean
71+
// ("proceeds") case needs a base whose entire ancestry is non-writable. t.TempDir()
72+
// lives under a world-writable /tmp (1777), which would always trip the guard, so
73+
// the tree is built under the user's home dir instead and the proceeds assertion is
74+
// skipped on environments whose HOME itself sits under a writable ancestor.
75+
//
76+
// The helper walks BOTH the lexical ancestor chain of the original path AND the
77+
// chain of the symlink-resolved real path; either chain being writable is unsafe.
78+
// EvalSymlinks requires the path to exist, so cfgDir leaves are created (not just
79+
// their parents) in these cases. It also treats relative and unresolvable paths as
80+
// unsafe.
81+
func TestHasWritableAncestor(t *testing.T) {
82+
home, err := os.UserHomeDir()
83+
require.NoError(t, err)
84+
85+
// base is a freshly created, 0755 dir under HOME; only its ancestry (HOME and
86+
// above) plus whatever we loosen below can be writable.
87+
base, err := os.MkdirTemp(home, "hwa-test-")
88+
require.NoError(t, err)
89+
t.Cleanup(func() { _ = os.RemoveAll(base) })
90+
require.NoError(t, os.Chmod(base, 0o755))
91+
92+
t.Run("relative path => unsafe", func(t *testing.T) {
93+
// A relative config dir can't be reasoned about (real ancestry depends on
94+
// cwd), so it must be treated as unsafe regardless of the filesystem.
95+
assert.True(t, hasWritableAncestor(filepath.Join("foo", "basecamp")))
96+
})
97+
98+
t.Run("non-writable ancestors => chmod proceeds", func(t *testing.T) {
99+
if hasWritableAncestor(base) {
100+
t.Skip("HOME has a writable ancestor; cannot demonstrate the proceeds case here")
101+
}
102+
grandparent := filepath.Join(base, "ok-gp")
103+
parent := filepath.Join(grandparent, "parent")
104+
cfgDir := filepath.Join(parent, "basecamp")
105+
require.NoError(t, os.MkdirAll(cfgDir, 0o755))
106+
107+
assert.False(t, hasWritableAncestor(cfgDir))
108+
})
109+
110+
t.Run("writable immediate parent => chmod skipped", func(t *testing.T) {
111+
parent := filepath.Join(base, "wp-parent")
112+
cfgDir := filepath.Join(parent, "basecamp")
113+
require.NoError(t, os.MkdirAll(cfgDir, 0o755))
114+
require.NoError(t, os.Chmod(parent, 0o777))
115+
116+
assert.True(t, hasWritableAncestor(cfgDir))
117+
})
118+
119+
t.Run("non-writable parent but writable grandparent => chmod skipped", func(t *testing.T) {
120+
grandparent := filepath.Join(base, "wgp-gp")
121+
parent := filepath.Join(grandparent, "parent")
122+
cfgDir := filepath.Join(parent, "basecamp")
123+
require.NoError(t, os.MkdirAll(cfgDir, 0o755))
124+
// Loosen the grandparent only; the immediate parent stays 0755.
125+
require.NoError(t, os.Chmod(grandparent, 0o777))
126+
127+
assert.True(t, hasWritableAncestor(cfgDir))
128+
})
129+
130+
t.Run("symlink into writable real ancestor => chmod skipped", func(t *testing.T) {
131+
if hasWritableAncestor(base) {
132+
t.Skip("HOME has a writable ancestor; cannot isolate the symlinked-ancestor case here")
133+
}
134+
// Real target lives under a world-writable ancestor (writable/real-cfg),
135+
// but the lexical path to the symlink (safe/link) has only non-writable
136+
// ancestors. Lexical walking would miss the writable ancestor; symlink
137+
// resolution must catch it.
138+
writable := filepath.Join(base, "writable")
139+
realCfg := filepath.Join(writable, "real-cfg")
140+
require.NoError(t, os.MkdirAll(realCfg, 0o755))
141+
require.NoError(t, os.Chmod(writable, 0o777))
142+
143+
safe := filepath.Join(base, "safe")
144+
require.NoError(t, os.MkdirAll(safe, 0o755))
145+
link := filepath.Join(safe, "link")
146+
if err := os.Symlink(realCfg, link); err != nil {
147+
t.Skipf("symlinks unavailable in this environment: %v", err)
148+
}
149+
150+
assert.True(t, hasWritableAncestor(link))
151+
})
152+
153+
t.Run("symlink whose lexical parent is writable but target tree is private => chmod skipped", func(t *testing.T) {
154+
if hasWritableAncestor(base) {
155+
t.Skip("HOME has a writable ancestor; cannot isolate the writable-symlink-parent case here")
156+
}
157+
// The dual to the prior case: here the symlink's TARGET tree is entirely
158+
// private (0755) but the world-writable dir holding the symlink is in the
159+
// LEXICAL chain only — EvalSymlinks jumps to the target and skips it, so a
160+
// resolved-only walk returns "safe". A local user with write access to the
161+
// writable dir can swap the symlink between our Lstat and Chmod, so the
162+
// lexical chain must catch it.
163+
realTree := filepath.Join(base, "private-real", "sub")
164+
require.NoError(t, os.MkdirAll(realTree, 0o755))
165+
166+
writable := filepath.Join(base, "world-writable")
167+
require.NoError(t, os.MkdirAll(writable, 0o755))
168+
require.NoError(t, os.Chmod(writable, 0o777))
169+
link := filepath.Join(writable, "link")
170+
if err := os.Symlink(realTree, link); err != nil {
171+
t.Skipf("symlinks unavailable in this environment: %v", err)
172+
}
173+
174+
// link/leaf resolves into the private tree, but its lexical parent chain
175+
// runs through the world-writable dir.
176+
assert.True(t, hasWritableAncestor(filepath.Join(link, "leaf")))
177+
})
178+
}
179+
180+
// TestIsForeignOwnerWritable exercises the foreign-owner half of the TOCTOU guard
181+
// directly. The helper only flags ancestors owned by a DIFFERENT non-root user
182+
// that still carry the owner-write bit; root-owned and self-owned dirs are trusted
183+
// regardless of their owner-write bit. Most cases run as any user; the genuine
184+
// "true" case requires chown'ing to a foreign uid, which only root can do, so it is
185+
// skipped otherwise.
186+
func TestIsForeignOwnerWritable(t *testing.T) {
187+
home, err := os.UserHomeDir()
188+
require.NoError(t, err)
189+
base, err := os.MkdirTemp(home, "ifow-test-")
190+
require.NoError(t, err)
191+
t.Cleanup(func() { _ = os.RemoveAll(base) })
192+
193+
t.Run("self-owned 0700 => false", func(t *testing.T) {
194+
dir := filepath.Join(base, "self-0700")
195+
require.NoError(t, os.Mkdir(dir, 0o700))
196+
fi, err := os.Stat(dir)
197+
require.NoError(t, err)
198+
assert.False(t, isForeignOwnerWritable(fi))
199+
})
200+
201+
t.Run("self-owned 0755 => false (uid==self; group/world bits handled elsewhere)", func(t *testing.T) {
202+
dir := filepath.Join(base, "self-0755")
203+
require.NoError(t, os.Mkdir(dir, 0o755))
204+
require.NoError(t, os.Chmod(dir, 0o755))
205+
fi, err := os.Stat(dir)
206+
require.NoError(t, err)
207+
// Owned by us, so the foreign-owner check is false even with the
208+
// owner-write bit set; group/world-writability is a separate concern
209+
// handled by hasWritableChain, not this helper.
210+
assert.False(t, isForeignOwnerWritable(fi))
211+
})
212+
213+
t.Run("foreign non-root owner with owner-write => true", func(t *testing.T) {
214+
// A genuine foreign-owned, owner-writable dir can only be produced by
215+
// chown'ing to another uid, which requires root. Skip otherwise.
216+
if os.Geteuid() != 0 {
217+
t.Skip("need root to chown a dir to a foreign uid")
218+
}
219+
dir := filepath.Join(base, "foreign-0755")
220+
require.NoError(t, os.Mkdir(dir, 0o755))
221+
// Chown to a non-root, non-self uid (nobody-ish). Skip if it fails.
222+
const foreignUID = 65534 // conventionally "nobody"
223+
if err := os.Chown(dir, foreignUID, foreignUID); err != nil {
224+
t.Skipf("cannot chown to foreign uid %d: %v", foreignUID, err)
225+
}
226+
fi, err := os.Stat(dir)
227+
require.NoError(t, err)
228+
assert.True(t, isForeignOwnerWritable(fi))
229+
})
230+
}
231+
62232
func TestResolvePreferences(t *testing.T) {
63233
boolPtr := func(b bool) *bool { return &b }
64234
intPtr := func(i int) *int { return &i }

internal/commands/skill.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -344,7 +344,8 @@ func RefreshSkillsIfVersionChanged() bool {
344344
// On transient failure, leave the sentinel stale so the next run retries.
345345
needsRefresh := baselineSkillInstalled()
346346
if !needsRefresh || refreshed {
347-
_ = os.MkdirAll(filepath.Dir(sentinelPath), 0o755) //nolint:gosec // G301: config dir
347+
// 0o700: GlobalConfigDir can hold credentials.json; keep it owner-only.
348+
_ = os.MkdirAll(filepath.Dir(sentinelPath), 0o700)
348349
_ = os.WriteFile(sentinelPath, []byte(version.Version), 0o644) //nolint:gosec // G306: not a secret
349350
}
350351

internal/commands/update_notice.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,7 @@ func writeUpdateCache(latestVersion string) {
130130
return
131131
}
132132
dir := filepath.Dir(updateCachePath())
133-
_ = os.MkdirAll(dir, 0o755) //nolint:gosec // G301: config dir
133+
// 0o700: GlobalConfigDir can hold credentials.json; keep it owner-only.
134+
_ = os.MkdirAll(dir, 0o700)
134135
_ = os.WriteFile(updateCachePath(), data, 0o644) //nolint:gosec // G306: not a secret
135136
}

internal/commands/wizard_agents.go

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -321,7 +321,20 @@ func removeStaleClaudePlugins(ctx context.Context, claudePath string, plugins []
321321
for _, p := range plugins {
322322
if len(p.Scopes) > 0 {
323323
anyRemoved := false
324+
// allScopesInvalid stays true only when no scope ever passed
325+
// validPluginScope (i.e. no scoped uninstall was even attempted).
326+
// We must NOT fall back to an unscoped removal just because a
327+
// scoped uninstall failed at runtime — that would wrongly strip
328+
// every-scope install when the targeted ones merely errored.
329+
allScopesInvalid := true
324330
for _, scope := range p.Scopes {
331+
// scope comes from installed_plugins.json (not first-party).
332+
// Whitelist it so a "-"-leading value can't inject a flag into
333+
// the uninstall argv.
334+
if !validPluginScope(scope) {
335+
continue
336+
}
337+
allScopesInvalid = false
325338
c := exec.CommandContext(ctx, claudePath, "plugin", "uninstall", p.Key, "--scope", scope) //nolint:gosec // G204: claudePath from FindClaudeBinary
326339
if err := c.Run(); err == nil {
327340
anyRemoved = true
@@ -333,6 +346,21 @@ func removeStaleClaudePlugins(ctx context.Context, claudePath string, plugins []
333346
}
334347
if anyRemoved {
335348
removed = append(removed, p.Key)
349+
} else if allScopesInvalid {
350+
// Every scope was invalid (none passed validPluginScope), so no
351+
// scoped uninstall was attempted. Fall back to the unscoped retry
352+
// removal so the plugin isn't silently left installed.
353+
n := 0
354+
for i := 0; i < 10; i++ {
355+
c := exec.CommandContext(ctx, claudePath, "plugin", "uninstall", p.Key) //nolint:gosec // G204: claudePath from FindClaudeBinary
356+
if err := c.Run(); err != nil {
357+
break
358+
}
359+
n++
360+
}
361+
if n > 0 {
362+
removed = append(removed, p.Key)
363+
}
336364
}
337365
} else {
338366
n := 0
@@ -351,6 +379,24 @@ func removeStaleClaudePlugins(ctx context.Context, claudePath string, plugins []
351379
return removed, scopes
352380
}
353381

382+
// validPluginScope reports whether scope is one of Claude's accepted plugin
383+
// scopes. Used to gate untrusted scope values from installed_plugins.json
384+
// before they reach `claude plugin uninstall --scope <scope>`.
385+
//
386+
// `claude plugin uninstall --scope` only accepts user/project/local, so a
387+
// "global"-scoped entry is invalid here: leaving it valid would make a scoped
388+
// uninstall fail silently while suppressing the unscoped fallback, stranding
389+
// the plugin. Treating "global" as invalid keeps allScopesInvalid true so the
390+
// unscoped fallback removes it.
391+
func validPluginScope(scope string) bool {
392+
switch scope {
393+
case "user", "project", "local":
394+
return true
395+
default:
396+
return false
397+
}
398+
}
399+
354400
// claudeManualInstallHint returns the two-line manual install instructions.
355401
func claudeManualInstallHint(styles *tui.Styles) (string, string) {
356402
return styles.Bold.Render(fmt.Sprintf(" claude plugin marketplace add %s", harness.ClaudeMarketplaceSource)),

0 commit comments

Comments
 (0)