Skip to content

fix: unquote environment variable passed to hook commands#410

Open
zimeg wants to merge 6 commits intomainfrom
zimeg-fix-env-quote
Open

fix: unquote environment variable passed to hook commands#410
zimeg wants to merge 6 commits intomainfrom
zimeg-fix-env-quote

Conversation

@zimeg
Copy link
Member

@zimeg zimeg commented Mar 18, 2026

Changelog

Fixes environment variable values set for hook commands to no longer be surrounded with double quotes.

Summary

This PR fixes an issue where environment variables were set with double quotes surrounding values:

// MapToStringSlice converts a map[string]string to a slice of strings with
// elements key="value" from the map with an optional prefix
func MapToStringSlice(args map[string]string, prefix string) []string {
var res = []string{}
for name, value := range args {
if len(value) > 0 {
var escapedValue string
if runtime.GOOS == "windows" {
escapedValue = strings.ReplaceAll(value, `"`, "`\"")
} else {
escapedValue = strings.ReplaceAll(value, `"`, `\"`)
}
res = append(res, fmt.Sprintf(`%s%s="%s"`, prefix, name, escapedValue))
}
}
return res
}

	// Env specifies the environment of the process.
	// Each entry is of the form "key=value".

📚 Reference: https://pkg.go.dev/os/exec#Cmd

Requirements

@zimeg zimeg added this to the Next Release milestone Mar 18, 2026
@zimeg zimeg self-assigned this Mar 18, 2026
@zimeg zimeg added bug M-T: confirmed bug report. Issues are confirmed when the reproduction steps are documented semver:patch Use on pull requests to describe the release version increment labels Mar 18, 2026
@codecov
Copy link

codecov bot commented Mar 18, 2026

Codecov Report

❌ Patch coverage is 50.00000% with 2 lines in your changes missing coverage. Please review.
✅ Project coverage is 68.62%. Comparing base (8b31c6a) to head (5122e45).

Files with missing lines Patch % Lines
internal/pkg/platform/localserver.go 0.00% 2 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main     #410      +/-   ##
==========================================
- Coverage   68.62%   68.62%   -0.01%     
==========================================
  Files         218      218              
  Lines       18162    18164       +2     
==========================================
+ Hits        12464    12465       +1     
- Misses       4538     4539       +1     
  Partials     1160     1160              

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

Copy link
Member Author

@zimeg zimeg left a comment

Choose a reason for hiding this comment

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

💌 Notes on code for the amazing reviewers! I am also planning to follow up with testing notes before asking for particular review 🧪 ✨

var cmdEnvVars = os.Environ()
cmdEnvVars = append(cmdEnvVars, goutils.MapToStringSlice(opts.Env, "")...)
for name, value := range opts.Env {
cmdEnvVars = append(cmdEnvVars, name+"="+value)
Copy link
Member Author

Choose a reason for hiding this comment

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

🔭 note: These values are set for both the default and message-boundaries protocols:

cmd := opts.Exec.Command(cmdEnvVars, stdout, stderr, opts.Stdin, cmdArgs[0], cmdArgVars...)

cmd := opts.Exec.Command(cmdEnvVars, &stdout, stderr, opts.Stdin, cmdArgs[0], cmdArgVars...)

// Command creates a command ready to be run with the current processes shell
func (sh ShellExec) Command(env []string, stdout io.Writer, stderr io.Writer, stdin io.Reader, name string, arg ...string) ShellCommand {
cmd := sh.command(name, arg...)
cmd.Env = env

// To avoid removing any environment variables that are set in the current environment, we first set the cmd.Env to the current environment.
// before adding any new environment variables.
var cmdEnvVars = os.Environ()
cmdEnvVars = append(cmdEnvVars, goutils.MapToStringSlice(sdkManagedConnectionStartHookOpts.Env, "")...)
Copy link
Member Author

Choose a reason for hiding this comment

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

🏁 note: I understand the MapToStringSlice utilities to be useful in escaping command line arguments but we might not need this with the direct environment variable value setting in commands.

🏆 ramble: This implementation in localserver is the other find of a direct call to the hooks command executor so shares the change!

@zimeg
Copy link
Member Author

zimeg commented Mar 19, 2026

🔬 More findings on the scope of this bug and confidence in a safe changes!

🐛 Hooks impacted

This PR changes how environment variables are set for both hook scripts and the run command for SDK managed connections. Our current hook implementations, however, don't make use of variables set from the CLI right now and so no problems were noticed here.

The run command for SDK managed connections had two environment variables set in hooks that can be used to demonstrate this error:

// StartDelegate passes along required opts to SDK, delegating
// connection for running app locally to script hook start
func (r *LocalServer) StartDelegate(ctx context.Context) error {
// Set up hook execution options
var sdkManagedConnectionStartHookOpts = hooks.HookExecOpts{
Env: map[string]string{
"SLACK_CLI_XAPP": r.token,
"SLACK_CLI_XOXB": r.localHostedContext.BotAccessToken,
},
Exec: hooks.ShellExec{},
Hook: r.clients.SDKConfig.Hooks.Start,
}
// Check whether hook script is available
if !r.clients.SDKConfig.Hooks.Start.IsAvailable() {
return slackerror.New(slackerror.ErrSDKHookNotFound).WithMessage("The command for '%s' was not found", r.clients.SDKConfig.Hooks.Start.Name)
}
cmdStr, err := r.clients.SDKConfig.Hooks.Start.Get()
if err != nil {
return slackerror.New(slackerror.ErrSDKHookNotFound).WithRootCause(err)
}
// We're taking the script and separating it into individual fields to be compatible with Exec.Command,
// then appending any additional arguments as flag --key=value pairs.
cmdArgs := strings.Fields(cmdStr)
var cmdArgVars = cmdArgs[1:] // omit the first item because that is the command name
// Whatever cmd.Env is set to will be the ONLY environment variables that the `cmd` will have access to when it runs.
// To avoid removing any environment variables that are set in the current environment, we first set the cmd.Env to the current environment.
// before adding any new environment variables.
var cmdEnvVars = os.Environ()
cmdEnvVars = append(cmdEnvVars, goutils.MapToStringSlice(sdkManagedConnectionStartHookOpts.Env, "")...)
cmd := sdkManagedConnectionStartHookOpts.Exec.Command(cmdEnvVars, os.Stdout, os.Stderr, nil, cmdArgs[0], cmdArgVars...)

$ slack create asdf -t slack-samples/bolt-js-starter-template
$ cd asdf
$ vim app.js
const app = new App({
  ...
  socketMode: true,
  appToken: process.env.SLACK_CLI_XAPP,  // Change this from SLACK_APP_TOKEN to SLACK_CLI_XAPP
});
$ slack run
...
[ERROR]  bolt-app Failed to start the app Error: An API error occurred: invalid_auth

With the changes of this branch, the token is passed as the expected string! The common SLACK_BOT_TOKEN used in templates avoided this issue because it's set using different logic:

// setAppEnvironmentTokens adds the app and bot token to the process environment
func setAppEnvironmentTokens(ctx context.Context, clients *shared.ClientFactory, result api.DeveloperAppInstallResult) error {
if token, ok := clients.Os.LookupEnv("SLACK_APP_TOKEN"); !ok {
if err := clients.Os.Setenv("SLACK_APP_TOKEN", result.APIAccessTokens.AppLevel); err != nil {
return err
}
} else if token != result.APIAccessTokens.AppLevel {
clients.IO.PrintWarning(ctx, "%s", style.Sectionf(style.TextSection{
Text: fmt.Sprintf("The app token differs from the set %s environment variable", style.Highlight("SLACK_APP_TOKEN")),
Secondary: []string{
"The environment variable will continue to be used",
"Proceed with caution as this might be associated to an unexpected app ID",
},
}))
}
if token, ok := clients.Os.LookupEnv("SLACK_BOT_TOKEN"); !ok {
if err := clients.Os.Setenv("SLACK_BOT_TOKEN", result.APIAccessTokens.Bot); err != nil {
return err
}
} else if token != result.APIAccessTokens.Bot {
clients.IO.PrintWarning(ctx, "%s", style.Sectionf(style.TextSection{
Text: fmt.Sprintf("The bot token differs from the set %s environment variable", style.Highlight("SLACK_BOT_TOKEN")),
Secondary: []string{
"The environment variable will continue to be used",
"Proceed with caution as this might be associated to an unexpected bot ID",
},
}))
}
return nil
}

The run command for Deno apps passes environment variables in context through the CLI managed connection:

// Gather environment variables from an environment file
variables, err := clients.Config.GetDotEnvFileVariables()
if err != nil {
return "", slackerror.Wrap(err, slackerror.ErrLocalAppRun).
WithMessage("Failed to read the local .env file")
}
// Set SLACK_API_URL to the resolved host value found in the environment
if value, ok := variables["SLACK_API_URL"]; ok {
_ = clients.Os.Setenv("SLACK_API_URL", value)
} else {
variables["SLACK_API_URL"] = fmt.Sprintf("%s/api/", clients.Config.APIHostResolved)
}
var localHostedContext = LocalHostedContext{
BotAccessToken: localInstallResult.APIAccessTokens.Bot,
AppID: installedApp.AppID,
TeamID: *authSession.TeamID,
Variables: variables,
}
var server = LocalServer{
clients: clients,
token: localInstallResult.APIAccessTokens.AppLevel,
localHostedContext: localHostedContext,
cliConfig: cliConfig,
Connection: nil,
}

var socketEvent = SocketEvent{
Body: msg.Payload,
Context: r.localHostedContext,
}
body, err := json.Marshal(socketEvent)
if err != nil {
errChan <- slackerror.Wrap(err, slackerror.ErrSocketConnection)
return
}
_, err = r.clients.SDKConfig.Hooks.Start.Get()
if err != nil {
errChan <- err
return
}
// Mimic the hosted app by executing the SDKs run command with the message as a param
var startHookOpts = hooks.HookExecOpts{
Hook: r.clients.SDKConfig.Hooks.Start,
Stdin: bytes.NewBuffer(body),
Stdout: r.clients.IO.WriteSecondary(r.clients.IO.WriteOut()),
Stderr: r.clients.IO.WriteSecondary(r.clients.IO.WriteErr()),
}
out, err := r.clients.HookExecutor.Execute(ctx, startHookOpts)

Overall I'm feeling alright that this change is a fix for unnoticed bugs in our hook implementation and we'll match specs closer more 🤓

📣 Please do callout misunderstandings in these scopes! I'm hoping these findings can be useful in testing too!

@zimeg zimeg marked this pull request as ready for review March 19, 2026 05:13
@zimeg zimeg requested a review from a team as a code owner March 19, 2026 05:13
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug M-T: confirmed bug report. Issues are confirmed when the reproduction steps are documented semver:patch Use on pull requests to describe the release version increment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant