Skip to content

Commit ad2b30a

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 ffb8fe1 commit ad2b30a

5 files changed

Lines changed: 202 additions & 2 deletions

File tree

internal/cli/root.go

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,30 @@ 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 cfgDir's parent is world- or group-writable
57+
// (or can't be stat'd): a local user with write access to the
58+
// parent could swap the dir for a symlink between our Lstat and
59+
// Chmod, making the pair racy (Lstat→Chmod TOCTOU). Group members
60+
// with write access can win the same race, so guard on 0o022 (group
61+
// + world write), not just the world-writable bit. This covers both
62+
// the os.TempDir() fallback and XDG_CONFIG_HOME=/tmp. We only harden
63+
// the dir when its parent isn't attacker-writable.
64+
if parent, statErr := os.Stat(filepath.Dir(cfgDir)); statErr != nil || parent.Mode()&0o022 != 0 {
65+
// world/group-writable (or unstattable) parent => skip chmod
66+
} else if fi, lstatErr := os.Lstat(cfgDir); lstatErr == nil && fi.IsDir() && fi.Mode()&os.ModeSymlink == 0 {
67+
_ = os.Chmod(cfgDir, 0o700) //nolint:gosec // G302: 0700 is correct for a directory (needs the execute bit) that can hold credentials.json
68+
}
69+
}
70+
4771
// Start background update check early so it runs during command execution
4872
updateCheck = commands.StartUpdateCheck()
4973

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)),
Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
package commands
2+
3+
import (
4+
"context"
5+
"os"
6+
"path/filepath"
7+
"testing"
8+
9+
"github.com/stretchr/testify/assert"
10+
"github.com/stretchr/testify/require"
11+
12+
"github.com/basecamp/basecamp-cli/internal/harness"
13+
)
14+
15+
// TestValidPluginScope guards the argv-injection whitelist: scope values come
16+
// from installed_plugins.json (not first-party), so a "-"-leading value must
17+
// not reach `claude plugin uninstall --scope <scope>`.
18+
func TestValidPluginScope(t *testing.T) {
19+
valid := []string{"user", "project", "local"}
20+
for _, s := range valid {
21+
if !validPluginScope(s) {
22+
t.Errorf("validPluginScope(%q) = false, want true", s)
23+
}
24+
}
25+
26+
// "global" is not a scope `claude plugin uninstall --scope` accepts, so it
27+
// must be treated as invalid: keeping it valid would make a scoped uninstall
28+
// fail silently while suppressing the unscoped fallback, stranding the plugin.
29+
invalid := []string{"", "global", "-rf", "--force", "User", "system", "/etc", "user ", " user"}
30+
for _, s := range invalid {
31+
if validPluginScope(s) {
32+
t.Errorf("validPluginScope(%q) = true, want false", s)
33+
}
34+
}
35+
}
36+
37+
// stubClaudeUninstall writes a stub `claude` binary that logs every invocation
38+
// to logFile and exits with failScoped/failUnscoped for uninstall calls. The
39+
// first unscoped uninstall succeeds (so the retry loop runs once) and repeats
40+
// fail, mirroring real "entry gone" behavior. Returns its absolute path.
41+
func stubClaudeUninstall(t *testing.T, failScoped bool) string {
42+
t.Helper()
43+
dir := t.TempDir()
44+
logFile := filepath.Join(dir, "calls.log")
45+
markerDir := filepath.Join(dir, "markers")
46+
require.NoError(t, os.MkdirAll(markerDir, 0o755))
47+
48+
scopedExit := "0"
49+
if failScoped {
50+
scopedExit = "1"
51+
}
52+
script := "#!/bin/sh\n" +
53+
"echo \"$*\" >> \"" + logFile + "\"\n" +
54+
"case \"$1 $2\" in\n" +
55+
" \"plugin uninstall\")\n" +
56+
" if [ \"$4\" = \"--scope\" ]; then exit " + scopedExit + "; fi\n" +
57+
// unscoped: succeed once per key, then fail so the retry loop ends.
58+
" MARKER=\"" + markerDir + "/$3.removed\"\n" +
59+
" if [ ! -f \"$MARKER\" ]; then > \"$MARKER\"; exit 0; fi\n" +
60+
" exit 1\n" +
61+
" ;;\n" +
62+
" *) exit 0 ;;\n" +
63+
"esac\n"
64+
path := filepath.Join(dir, "claude")
65+
require.NoError(t, os.WriteFile(path, []byte(script), 0o755)) //nolint:gosec // G306: test helper
66+
return path
67+
}
68+
69+
func readClaudeCalls(t *testing.T, claudePath string) string {
70+
t.Helper()
71+
data, err := os.ReadFile(filepath.Join(filepath.Dir(claudePath), "calls.log"))
72+
if os.IsNotExist(err) {
73+
return ""
74+
}
75+
require.NoError(t, err)
76+
return string(data)
77+
}
78+
79+
// TestRemoveStaleClaudePluginsAllScopesInvalid verifies the YL7 fix: when every
80+
// recorded scope fails validPluginScope (no scoped uninstall is attempted), we
81+
// fall back to an unscoped removal so the plugin isn't silently left installed.
82+
func TestRemoveStaleClaudePluginsAllScopesInvalid(t *testing.T) {
83+
claude := stubClaudeUninstall(t, false)
84+
plugins := []harness.StalePlugin{{Key: "basecamp@37signals", Scopes: []string{"-rf", "--force"}}}
85+
86+
removed, scopes := removeStaleClaudePlugins(context.Background(), claude, plugins)
87+
88+
calls := readClaudeCalls(t, claude)
89+
assert.NotContains(t, calls, "--scope", "no scoped uninstall should be attempted for invalid scopes")
90+
assert.Contains(t, calls, "plugin uninstall basecamp@37signals", "unscoped fallback should run")
91+
assert.Equal(t, []string{"basecamp@37signals"}, removed)
92+
assert.Empty(t, scopes)
93+
}
94+
95+
// TestRemoveStaleClaudePluginsGlobalScopeFallsBack verifies that a stale entry
96+
// whose only recorded scope is "global" (which `claude plugin uninstall --scope`
97+
// rejects) is treated as all-invalid, so the unscoped fallback removes it rather
98+
// than leaving it silently installed behind a failing scoped uninstall.
99+
func TestRemoveStaleClaudePluginsGlobalScopeFallsBack(t *testing.T) {
100+
claude := stubClaudeUninstall(t, false)
101+
plugins := []harness.StalePlugin{{Key: "basecamp@37signals", Scopes: []string{"global"}}}
102+
103+
removed, scopes := removeStaleClaudePlugins(context.Background(), claude, plugins)
104+
105+
calls := readClaudeCalls(t, claude)
106+
assert.NotContains(t, calls, "--scope", "no scoped uninstall should be attempted for a global scope")
107+
assert.Contains(t, calls, "plugin uninstall basecamp@37signals", "unscoped fallback should run")
108+
assert.Equal(t, []string{"basecamp@37signals"}, removed)
109+
assert.Empty(t, scopes)
110+
}
111+
112+
// TestRemoveStaleClaudePluginsValidScopeUninstallFails verifies the regression
113+
// fix: when scopes are VALID but the scoped uninstall fails at runtime, we must
114+
// NOT fall back to an unscoped removal (which would wrongly strip every scope).
115+
func TestRemoveStaleClaudePluginsValidScopeUninstallFails(t *testing.T) {
116+
claude := stubClaudeUninstall(t, true)
117+
plugins := []harness.StalePlugin{{Key: "basecamp@37signals", Scopes: []string{"user", "project"}}}
118+
119+
removed, scopes := removeStaleClaudePlugins(context.Background(), claude, plugins)
120+
121+
calls := readClaudeCalls(t, claude)
122+
assert.Contains(t, calls, "plugin uninstall basecamp@37signals --scope user")
123+
assert.Contains(t, calls, "plugin uninstall basecamp@37signals --scope project")
124+
assert.NotContains(t, calls, "plugin uninstall basecamp@37signals\n",
125+
"no unscoped fallback when valid scopes were attempted but failed")
126+
assert.Empty(t, removed)
127+
assert.Empty(t, scopes)
128+
}

0 commit comments

Comments
 (0)