Skip to content

fix: prevent completions from mutating os.Args via append side effect#2356

Open
veeceey wants to merge 3 commits intospf13:mainfrom
veeceey:fix/completions-osargs-mutation
Open

fix: prevent completions from mutating os.Args via append side effect#2356
veeceey wants to merge 3 commits intospf13:mainfrom
veeceey:fix/completions-osargs-mutation

Conversation

@veeceey
Copy link

@veeceey veeceey commented Feb 10, 2026

Summary

  • Fixes a bug where getCompletions() accidentally mutates os.Args by writing "--" into the shared backing array via an append with spare capacity
  • Uses a three-index slice expression (finalArgs[:len:len]) to cap the slice capacity, forcing append to allocate a new array
  • Adds a regression test that confirms the args slice is not corrupted during shell completion

Details

When shell completions are requested (e.g., prog __complete x), getCompletions() calls:

_ = finalCmd.ParseFlags(append(finalArgs, "--"))

finalArgs is a sub-slice of the original args (derived from os.Args[1:]), and if it has spare capacity in its backing array, append writes "--" directly into the shared memory rather than allocating a new slice. This corrupts os.Args, which users may be inspecting in their ValidArgsFunction.

The fix uses finalArgs[:len(finalArgs):len(finalArgs)] to limit the capacity to the length, guaranteeing that append allocates a fresh backing array.

The bug is most visible with TraverseChildren: true, since Traverse() returns sub-slices that preserve the original backing array's capacity.

Fixes #2257

Test plan

  • Added TestCompletionDoesNotMutateArgs that fails without the fix and passes with it
  • Full test suite passes (go test ./...)

When getCompletions() checks for interspersed flags, it calls
append(finalArgs, "--") to temporarily add a "--" sentinel. However,
finalArgs is a sub-slice of the original args (ultimately derived from
os.Args[1:] or SetArgs), and if the sub-slice has spare capacity in its
backing array, append writes "--" into the shared array, corrupting
the caller's data.

This is particularly visible when TraverseChildren is enabled: the
Traverse method returns sub-slices that share the original backing
array. A user's ValidArgsFunction inspecting os.Args would then see
"--" where a real argument should be.

Fix this by using a three-index slice expression
(finalArgs[:len:len]) to limit the capacity, forcing append to
allocate a new backing array instead of writing into the shared one.

Fixes spf13#2257

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@CLAassistant
Copy link

CLAassistant commented Feb 10, 2026

CLA assistant check
All committers have signed the CLA.

@marckhouzam
Copy link
Collaborator

Wow that’s interesting! Would it be doable to have a test that actually checks that os.Args is unchanged (but wrongly changed without the fix)?

Rework TestCompletionDoesNotMutateArgs into
TestCompletionDoesNotMutateOsArgs that overrides os.Args and verifies
it is unchanged after shell completion, instead of using SetArgs with a
crafted slice. This exercises the real os.Args[1:] code path in
ExecuteC and makes the regression more obvious.
@veeceey
Copy link
Author

veeceey commented Feb 11, 2026

Good call! I reworked the test so it checks os.Args directly instead of going through SetArgs with a crafted slice.

The new TestCompletionDoesNotMutateOsArgs saves os.Args, overrides it with []string{"prog", "__completeNoDesc", "x"} (the program name has to be something other than cobra.test to bypass the test guard in ExecuteC), then runs completions without calling SetArgs — so it goes through the real os.Args[1:] path. After execution it checks that all three elements of os.Args are still intact.

Verified locally:

  • With the fix: test passes, os.Args untouched
  • Without the fix (reverted the three-index slice): test fails with os.Args[2] was mutated: expected "x", got "--" — exactly what the issue reporter was seeing
  • Full suite still green (go test ./...)

@veeceey
Copy link
Author

veeceey commented Feb 19, 2026

Friendly ping - just checking if the updated test approach looks good. Let me know if you'd like any further changes!

completions.go Outdated
// the extra added -- is counted as arg.
flagCompletion := true
_ = finalCmd.ParseFlags(append(finalArgs, "--"))
_ = finalCmd.ParseFlags(append(finalArgs[:len(finalArgs):len(finalArgs)], "--"))
Copy link
Contributor

@ccoVeille ccoVeille Feb 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is about that:

Fixes a bug where getCompletions() accidentally mutates os.Args by writing "--" into the shared backing array via an append with spare capacity
Uses a three-index slice expression (finalArgs[:len:len]) to cap the slice capacity, forcing append to allocate a new array

This sounds complicated

This could be used

argsCopy := make([]string, len(finalArgs))
copy(argsCopy, finalArgs)
_ = finalCmd.ParseFlags(append(argsCopy, "--"))

Or this, that's less obvious, but pretty simple to read.

_ = finalCmd.ParseFlags(append([]string{}, finalArgs, "--"))

I feel like your solution is the most performant but it sounds a bit cryptic

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah fair point, it is a bit cryptic if you're not used to the three-index slice idiom. The reason I went with finalArgs[:len(finalArgs):len(finalArgs)] is that it caps the capacity to the length, so when append adds "--" it's forced to allocate a new backing array instead of writing into the original one (which is os.Args). The make+copy approach works too but it's an extra allocation + copy of the whole slice when we really just need to prevent the append from mutating the original. The three-index slice is a single expression that does exactly that. That said, if the maintainers find it less readable I'm happy to switch to make+copy -- either way the bug fix is the same.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@veeceey Agreed on it being a bit too cryptic.
I like @ccoVeille second suggestion, but the syntax is not quite right.
What do you guys think of:
_ = finalCmd.ParseFlags(append(append([]string{}, finalArgs...), "--"))
If you don't like this, the explicit array copy is also fine.

Shell completion needs to be fast but remains a user-facing operation so does not need to be optimized perfectly: copying a two or three element array is fine.

Pease put a comment before it mentioning we first copy the finalArgs slice to a new array to avoid risking modifying os.Args which currently backs finalArgs.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How about copying the trimmedArgs slice at the beginning of the function getCompletions?

Instead of

	trimmedArgs := args[:len(args)-1]

use

	trimmedArgs := make([]string, len(args)-1)
	copy(trimmedArgs, args[:len(args)-1])

In the future, we can change to slices.Clone.

// We do NOT use SetArgs so the code falls through to os.Args[1:].
// The program name must not be "cobra.test" to bypass the test guard
// in ExecuteC().
os.Args = []string{"prog", ShellCompNoDescRequestCmd, "x"}
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: please use "root" instead of "prog" just to be consistent with the name of the root command below. I has no impact but may just slow down someone looking at it.

Copy trimmedArgs at the top of getCompletions instead of using
the three-index slice expression at the append site. This is
cleaner and prevents any future append from accidentally mutating
the caller's backing array.

Renamed "prog" to "root" in the test for consistency with the
root command variable name.
@veeceey
Copy link
Author

veeceey commented Mar 12, 2026

@alexandear good call, that's much cleaner — copying trimmedArgs at the top of getCompletions means we don't have to worry about any downstream appends mutating the original. Went with your make+copy approach and reverted the three-index slice back to a plain append(finalArgs, "--").

Also renamed "prog" to "root" per @marckhouzam's nit.

@veeceey
Copy link
Author

veeceey commented Mar 15, 2026

@alexandear good idea — using slices.Clone (or the copy approach for now) is much safer since it prevents any future mutation of the original args. I'll update it. Thanks!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Completions modify os.Args

5 participants