Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Prev Previous commit
Next Next commit
fix: keep safe-output config placeholders off disk
Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com>
  • Loading branch information
Copilot and pelikhan authored Jun 1, 2026
commit 1131de2db3092a277228fc7b4361c2d8d85fcc1d
17 changes: 16 additions & 1 deletion actions/setup/js/safe_output_handler_manager.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -257,6 +257,21 @@ function normalizeCommentMemoryId(memoryId, fallback = "default") {
return normalized.length > 0 ? normalized : fallback;
}

function redactSensitiveConfig(value) {
if (Array.isArray(value)) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

redactSensitiveConfig is duplicated verbatim across two modules. The two copies will diverge silently when the sensitive-key pattern is updated — one file gets patched, the other doesn't.

💡 Suggested fix

Extract into a shared config_redaction.cjs and require it from both files:

// config_redaction.cjs
function isSensitiveConfigKey(key) {
  return /token|secret|key|password|credential|private/i.test(key);
}
function redactSensitiveConfig(value) { ... }
module.exports = { redactSensitiveConfig };
// In both safe_outputs_config.cjs and safe_output_handler_manager.cjs
const { redactSensitiveConfig } = require('./config_redaction.cjs');

This is already a two-file inconsistency: safe_outputs_config.cjs extracts isSensitiveConfigKey as a named helper while safe_output_handler_manager.cjs inlines the regex. Centralising removes the surface area for divergence.

return value.map(redactSensitiveConfig);
}
if (value && typeof value === "object") {
return Object.fromEntries(
Object.entries(value).map(([key, nestedValue]) => [
key,
/token/i.test(key) ? "***REDACTED***" : redactSensitiveConfig(nestedValue),
]),
);
}
return value;
}

/**
* Load configuration for safe outputs
* Reads configuration from GH_AW_SAFE_OUTPUTS_HANDLER_CONFIG environment variable
Expand All @@ -269,7 +284,7 @@ function loadConfig() {

try {
const config = JSON.parse(process.env.GH_AW_SAFE_OUTPUTS_HANDLER_CONFIG);
core.info(`Loaded config from GH_AW_SAFE_OUTPUTS_HANDLER_CONFIG: ${JSON.stringify(config)}`);
core.info(`Loaded config from GH_AW_SAFE_OUTPUTS_HANDLER_CONFIG: ${JSON.stringify(redactSensitiveConfig(config))}`);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[/tdd] The new redactSensitiveConfig call here has no test coverage — the existing loadConfig tests don't exercise the GH_AW_SAFE_OUTPUTS_HANDLER_CONFIG path.

💡 Suggestion

Add a test in safe_output_handler_manager.test.cjs (or a new file) that:

  1. Sets GH_AW_SAFE_OUTPUTS_HANDLER_CONFIG to a JSON object containing a github-token field.
  2. Calls loadConfig().
  3. Asserts the logged output contains ***REDACTED*** and does not contain the real token value.

This mirrors the test added in safe_outputs_config.test.cjs and closes the coverage gap for this second log path.

// Normalize config keys: convert hyphens to underscores
return Object.fromEntries(Object.entries(config).map(([k, v]) => [k.replace(/-/g, "_"), v]));
} catch (error) {
Expand Down
36 changes: 34 additions & 2 deletions actions/setup/js/safe_outputs_config.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,38 @@ const { getErrorMessage } = require("./error_helpers.cjs");
const fs = require("fs");
const path = require("path");

function isSensitiveConfigKey(key) {
return /token/i.test(key);
}

function redactSensitiveConfig(value) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[/diagnose] redactSensitiveConfig is defined here and again at safe_output_handler_manager.cjs:260 — identical logic in two files.

💡 Suggestion

Extract to a shared helper module (e.g. config_utils.cjs) and require it from both files. A future change to the redaction pattern (e.g. adding password or secret) would then need to be made in only one place, and a single test suite covers both call-sites.

if (Array.isArray(value)) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Narrow redaction regex leaks non-token sensitive fields into debug logs. Any config key named password, secret, api_key, private_key, or credential will be logged in plaintext to the runner debug log.

💡 Suggested fix

Broaden the pattern in isSensitiveConfigKey (and mirror it in safe_output_handler_manager.cjs):

function isSensitiveConfigKey(key) {
  return /token|secret|key|password|credential|private/i.test(key);
}

Without this, a handler config like { "github_app_private_key": "...", "client_secret": "..." } will appear verbatim in Actions runner debug logs, potentially captured by log-forwarding or audit infrastructure. Token-only redaction is the most obvious case but far from the only one.

return value.map(redactSensitiveConfig);
}
if (value && typeof value === "object") {
return Object.fromEntries(
Object.entries(value).map(([key, nestedValue]) => [
key,
isSensitiveConfigKey(key) ? "***REDACTED***" : redactSensitiveConfig(nestedValue),
]),
);
}
return value;
}

function resolveEnvPlaceholders(value) {
if (Array.isArray(value)) {
return value.map(resolveEnvPlaceholders);
}
if (value && typeof value === "object") {
return Object.fromEntries(Object.entries(value).map(([key, nestedValue]) => [key, resolveEnvPlaceholders(nestedValue)]));
}
if (typeof value !== "string") {
return value;
}
return value.replace(/\$\{([A-Z_][A-Z0-9_]*)\}/g, (match, envName) => process.env[envName] ?? match);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[/diagnose] The regex [A-Z_][A-Z0-9_]* matches only uppercase env-var names, so a placeholder like ${my_secret} would silently pass through unresolved.

💡 Details

All GitHub Actions secret/input env vars generated by the Go compiler (WRITE_PROJECT_PAT, GH_AW_INPUT_*) are uppercase, so this isn't a current bug. But the restriction is invisible to callers and would cause a silent runtime failure if a lowercase or mixed-case placeholder ever appeared in a config.

Either document the uppercase-only contract with a comment, or broaden the regex to [A-Za-z_][A-Za-z0-9_]* to be defensive:

return value.replace(/\$\{([A-Za-z_][A-Za-z0-9_]*)\}/g, (match, envName) => process.env[envName] ?? match);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[/tdd] The ?? match fallback silently leaves an unresolved ${VARNAME} literal in the config when the env var isn't set — a missing secret would be passed as a placeholder string to the safe-output handler rather than causing an early, clear failure.

💡 Suggestion

Consider logging a warning (or throwing) when a placeholder cannot be resolved:

return value.replace(/\$\{([A-Z_][A-Z0-9_]*)\}/g, (match, envName) => {
  if (process.env[envName] === undefined) {
    server.warning?.(`Placeholder ${match} has no matching env var — it will be passed literally`);
  }
  return process.env[envName] ?? match;
});

A companion test case for the unresolved-placeholder path would help pin the intended behaviour.

}

/**

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Unresolved placeholder silently passes through as a literal string, causing confusing downstream failures. When a referenced env var is not set, process.env[envName] ?? match returns the raw ${VAR} string, which is then used as the token/URL value.

💡 Suggested fix

Emit a warning (or throw for required fields) when a placeholder remains unresolved:

return value.replace(/\$\{([A-Z_][A-Z0-9_]*)\}/g, (match, envName) => {
  const resolved = process.env[envName];
  if (resolved === undefined) {
    // Warn so operators see which variable was missing
    server?.warning?.(`Config placeholder ${match} is unset — passing through literally`);
  }
  return resolved ?? match;
});

Without this, a misconfigured workflow (missing secret binding) produces no startup error — the safe-outputs server starts, then fails later when it tries to authenticate with the literal string ${WRITE_PROJECT_PAT} as a token, which is extremely hard to diagnose.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Smoke test reply: threading check passed here.

Warning

Firewall blocked 6 domains

The following domains were blocked by the firewall during workflow execution:

  • accounts.google.com
  • android.clients.google.com
  • clients2.google.com
  • contentautofill.googleapis.com
  • safebrowsingohttpgateway.googleapis.com
  • www.google.com

To allow these domains, add them to the network.allowed list in your workflow frontmatter:

network:
  allowed:
    - defaults
    - "accounts.google.com"
    - "android.clients.google.com"
    - "clients2.google.com"
    - "contentautofill.googleapis.com"
    - "safebrowsingohttpgateway.googleapis.com"
    - "www.google.com"

See Network Configuration for more information.

📰 BREAKING: Report filed by Smoke Copilot · gpt54 4.1M

* @typedef {Object} LoadConfigResult
* @property {Record<string, any>} config - The processed configuration
Expand All @@ -30,7 +62,7 @@ function loadConfig(server) {
server.debug(`Config file content length: ${configFileContent.length} characters`);
// Don't log raw content to avoid exposing sensitive configuration data
server.debug(`Config file read successfully, attempting to parse JSON`);
safeOutputsConfigRaw = JSON.parse(configFileContent);
safeOutputsConfigRaw = resolveEnvPlaceholders(JSON.parse(configFileContent));
server.debug(`Successfully parsed config from file with ${Object.keys(safeOutputsConfigRaw).length} configuration keys`);
} else {
server.debug(`Config file does not exist at: ${configPath}`);
Expand All @@ -44,7 +76,7 @@ function loadConfig(server) {
}

const safeOutputsConfig = Object.fromEntries(Object.entries(safeOutputsConfigRaw).map(([k, v]) => [k.replace(/-/g, "_"), v]));
server.debug(`Final processed config: ${JSON.stringify(safeOutputsConfig)}`);
server.debug(`Final processed config: ${JSON.stringify(redactSensitiveConfig(safeOutputsConfig))}`);

// Handle GH_AW_SAFE_OUTPUTS with default fallback
// Default is /opt (read-only mount for agent container)
Expand Down
29 changes: 29 additions & 0 deletions actions/setup/js/safe_outputs_config.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,8 @@ describe("safe_outputs_config", () => {
// Clear environment variables
delete process.env.GH_AW_SAFE_OUTPUTS_CONFIG_PATH;
delete process.env.GH_AW_SAFE_OUTPUTS;
delete process.env.WRITE_PROJECT_PAT;
delete process.env.GH_AW_INPUT_TARGET_REPO;
});

describe("loadConfig", () => {
Expand Down Expand Up @@ -187,5 +189,32 @@ describe("safe_outputs_config", () => {
expect(mockServer.debug).toHaveBeenCalledWith(expect.stringContaining("Successfully parsed config"));
expect(mockServer.debug).toHaveBeenCalledWith(expect.stringContaining("Final processed config"));
});

it("should resolve env placeholders in memory without logging token values", () => {
const configDir = path.dirname(testConfigPath);
fs.mkdirSync(configDir, { recursive: true });
process.env.WRITE_PROJECT_PAT = "runtime-project-token";
process.env.GH_AW_INPUT_TARGET_REPO = "github/docs";

fs.writeFileSync(
testConfigPath,
JSON.stringify({
"update-project": {
"github-token": "${WRITE_PROJECT_PAT}",
"target-repo": "${GH_AW_INPUT_TARGET_REPO}",
},
}),
);

/** @type {import("./safe_outputs_config.cjs").LoadConfigResult} */
const result = loadConfig(mockServer);

expect(result.config.update_project["github-token"]).toBe("runtime-project-token");
expect(result.config.update_project["target-repo"]).toBe("github/docs");

const debugOutput = mockServer.debug.mock.calls.map(call => String(call[0])).join("\n");
expect(debugOutput).toContain("***REDACTED***");
expect(debugOutput).not.toContain("runtime-project-token");
});
});
});
94 changes: 47 additions & 47 deletions pkg/workflow/mcp_setup_generator.go
Original file line number Diff line number Diff line change
Expand Up @@ -260,35 +260,10 @@ func generateSafeOutputsSetup(c *Compiler, yaml *strings.Builder, safeOutputConf
return
}
yaml.WriteString(" - name: Generate Safe Outputs Config\n")
configSecrets := ExtractSecretsFromValue(safeOutputConfig)
configContextVars := ExtractGitHubContextExpressionsFromValue(safeOutputConfig)
configWorkflowInputs := ExtractWorkflowInputExpressionsFromValue(safeOutputConfig)
hasEnvVars := len(configSecrets) > 0 || len(configContextVars) > 0 || len(configWorkflowInputs) > 0
if hasEnvVars {
sanitizedConfig, envKeys, envValues := buildSafeOutputsConfigRuntimeData(safeOutputConfig)
if len(envKeys) > 0 {
yaml.WriteString(" env:\n")
envKeys := make([]string, 0, safeAllocationCapacity(len(configSecrets), len(configContextVars), len(configWorkflowInputs)))
envValues := make(map[string]string, safeAllocationCapacity(len(configSecrets), len(configContextVars), len(configWorkflowInputs)))
// addEnvValue deduplicates envKeys while allowing later sources to override
// the value in envValues for duplicate keys.
addEnvValue := func(key, value string) {
if _, exists := envValues[key]; !exists {
envKeys = append(envKeys, key)
}
envValues[key] = value
}
for k, v := range configWorkflowInputs {
addEnvValue(k, v)
}
for k, v := range configContextVars {
addEnvValue(k, v)
}
for k, v := range configSecrets {
addEnvValue(k, v)
}
sort.Strings(envKeys)
for _, varName := range envKeys {
yaml.WriteString(" " + varName + ": " + envValues[varName] + "\n")
}
writeStepEnvVars(yaml, envKeys, envValues)
}
yaml.WriteString(" run: |\n")
yaml.WriteString(" mkdir -p \"${RUNNER_TEMP}/gh-aw/safeoutputs\"\n")
Expand All @@ -300,25 +275,9 @@ func generateSafeOutputsSetup(c *Compiler, yaml *strings.Builder, safeOutputConf

delimiter := GenerateHeredocDelimiterFromSeed("SAFE_OUTPUTS_CONFIG", workflowData.FrontmatterHash)
if safeOutputConfig != "" {
if hasEnvVars {
sanitizedConfig := safeOutputConfig
for varName, secretExpr := range configSecrets {
sanitizedConfig = strings.ReplaceAll(sanitizedConfig, secretExpr, "${"+varName+"}")
}
for varName, ctxExpr := range configContextVars {
sanitizedConfig = strings.ReplaceAll(sanitizedConfig, ctxExpr, "${"+varName+"}")
}
for varName, inputExpr := range configWorkflowInputs {
sanitizedConfig = strings.ReplaceAll(sanitizedConfig, inputExpr, "${"+varName+"}")
}
yaml.WriteString(" cat > \"${RUNNER_TEMP}/gh-aw/safeoutputs/config.json\" << " + delimiter + "\n")
yaml.WriteString(" " + sanitizedConfig + "\n")
yaml.WriteString(" " + delimiter + "\n")
} else {
yaml.WriteString(" cat > \"${RUNNER_TEMP}/gh-aw/safeoutputs/config.json\" << '" + delimiter + "'\n")
yaml.WriteString(" " + safeOutputConfig + "\n")
yaml.WriteString(" " + delimiter + "\n")
}
yaml.WriteString(" cat > \"${RUNNER_TEMP}/gh-aw/safeoutputs/config.json\" << '" + delimiter + "'\n")
yaml.WriteString(" " + sanitizedConfig + "\n")
yaml.WriteString(" " + delimiter + "\n")
}

toolsMetaJSON, err := generateToolsMetaJSON(workflowData, c.markdownPath)
Expand Down Expand Up @@ -386,6 +345,8 @@ func generateSafeOutputsSetup(c *Compiler, yaml *strings.Builder, safeOutputConf
yaml.WriteString(" GH_AW_SAFE_OUTPUTS_TOOLS_PATH: ${{ runner.temp }}/gh-aw/safeoutputs/tools.json\n")
yaml.WriteString(" GH_AW_SAFE_OUTPUTS_CONFIG_PATH: ${{ runner.temp }}/gh-aw/safeoutputs/config.json\n")
yaml.WriteString(" GH_AW_MCP_LOG_DIR: /tmp/gh-aw/mcp-logs/safeoutputs\n")
safeOutputsConfigEnvKeys, safeOutputsConfigEnvValues := buildSafeOutputsConfigRuntimeEnvVars(safeOutputConfig)
writeStepEnvVars(yaml, safeOutputsConfigEnvKeys, safeOutputsConfigEnvValues)
yaml.WriteString(" run: |\n")
yaml.WriteString(" # Environment variables are set above to prevent template injection\n")
yaml.WriteString(" export DEBUG\n")
Comment on lines +348 to 352
Expand All @@ -395,11 +356,50 @@ func generateSafeOutputsSetup(c *Compiler, yaml *strings.Builder, safeOutputConf
yaml.WriteString(" export GH_AW_SAFE_OUTPUTS_TOOLS_PATH\n")
yaml.WriteString(" export GH_AW_SAFE_OUTPUTS_CONFIG_PATH\n")
yaml.WriteString(" export GH_AW_MCP_LOG_DIR\n")
for _, varName := range safeOutputsConfigEnvKeys {
yaml.WriteString(" export " + varName + "\n")
}
yaml.WriteString(" \n")
yaml.WriteString(" bash \"${RUNNER_TEMP}/gh-aw/actions/start_safe_outputs_server.sh\"\n")
yaml.WriteString(" \n")
}

func buildSafeOutputsConfigRuntimeEnvVars(safeOutputConfig string) ([]string, map[string]string) {
configSecrets := ExtractSecretsFromValue(safeOutputConfig)
configContextVars := ExtractGitHubContextExpressionsFromValue(safeOutputConfig)
configWorkflowInputs := ExtractWorkflowInputExpressionsFromValue(safeOutputConfig)
envValues := make(map[string]string, safeAllocationCapacity(len(configSecrets), len(configContextVars), len(configWorkflowInputs)))
addEnvValue := func(key, value string) {
envValues[key] = value
}
for k, v := range configWorkflowInputs {
addEnvValue(k, v)
}
for k, v := range configContextVars {
addEnvValue(k, v)
}
for k, v := range configSecrets {
addEnvValue(k, v)
}
return sortedMapKeys(envValues), envValues
}

func buildSafeOutputsConfigRuntimeData(safeOutputConfig string) (string, []string, map[string]string) {
sanitizedConfig := safeOutputConfig
envKeys, envValues := buildSafeOutputsConfigRuntimeEnvVars(safeOutputConfig)
for _, varName := range envKeys {
value := envValues[varName]
sanitizedConfig = strings.ReplaceAll(sanitizedConfig, value, "${"+varName+"}")
}
return sanitizedConfig, envKeys, envValues
}

func writeStepEnvVars(yaml *strings.Builder, envKeys []string, envValues map[string]string) {
for _, varName := range envKeys {
yaml.WriteString(" " + varName + ": " + envValues[varName] + "\n")
}
}

func generateMCPScriptsSetup(yaml *strings.Builder, workflowData *WorkflowData) error {
if !IsMCPScriptsEnabled(workflowData.MCPScripts) {
return nil
Expand Down
65 changes: 56 additions & 9 deletions pkg/workflow/safe_outputs_dynamic_allowed_repos_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -58,19 +58,66 @@ Test workflow
"Generate Safe Outputs Config step should map inputs.target_repo to an env var")
assert.Contains(t, compiled, "GH_AW_INPUT_BASE_BRANCH: ${{ inputs.base_branch }}",
"Generate Safe Outputs Config step should map inputs.base_branch to an env var")
assert.Equal(t, 2, strings.Count(compiled, "GH_AW_INPUT_TARGET_REPO: ${{ inputs.target_repo }}"),
"Dynamic input env vars should be available both when writing config.json and when starting the MCP server")
assert.Equal(t, 2, strings.Count(compiled, "GH_AW_INPUT_BASE_BRANCH: ${{ inputs.base_branch }}"),
"Dynamic input env vars should be available both when writing config.json and when starting the MCP server")
assert.Contains(t, compiled, `"allowed_repos":"${GH_AW_INPUT_TARGET_REPO}"`,
"config.json payload should use shell env var for allowed_repos")
"config.json payload should preserve env placeholder for allowed_repos")
assert.Contains(t, compiled, `"allowed_base_branches":"${GH_AW_INPUT_BASE_BRANCH}"`,
"config.json payload should use shell env var for allowed_base_branches")
"config.json payload should preserve env placeholder for allowed_base_branches")

quotedHeredocPattern := regexp.MustCompile(`cat > "\$\{RUNNER_TEMP\}/gh-aw/safeoutputs/config\.json" << 'GH_AW_SAFE_OUTPUTS_CONFIG_[0-9a-f]{16}_EOF'`)
assert.False(t, quotedHeredocPattern.MatchString(compiled),
"Safe outputs config heredoc should not be single-quoted when using dynamic input expressions")
assert.True(t, quotedHeredocPattern.MatchString(compiled),
"Safe outputs config heredoc should be single-quoted so placeholders are not expanded onto disk")

unquotedHeredocPattern := regexp.MustCompile(`cat > "\$\{RUNNER_TEMP\}/gh-aw/safeoutputs/config\.json" << GH_AW_SAFE_OUTPUTS_CONFIG_[0-9a-f]{16}_EOF`)
assert.True(t, unquotedHeredocPattern.MatchString(compiled),
"Safe outputs config heredoc should be unquoted when dynamic input expressions are present")
normalizedCompiled := strings.ReplaceAll(compiled, `\"`, `"`)
assert.NotContains(t, normalizedCompiled, `"allowed_repos":["${{ inputs.target_repo }}"]`,
"config.json payload should not keep unresolved workflow input expression")
assert.False(t, unquotedHeredocPattern.MatchString(compiled),
"Safe outputs config heredoc should never be unquoted for dynamic config placeholders")
}

func TestSafeOutputsConfigPreservesSecretPlaceholdersOnDisk(t *testing.T) {
tmpDir := testutil.TempDir(t, "safe-outputs-secret-placeholders")
mdFile := filepath.Join(tmpDir, "secret-safe-outputs.md")

content := `---
name: Secret Safe Outputs
on:
workflow_dispatch:
engine: copilot
safe-outputs:
update-project:
github-token: ${{ secrets.WRITE_PROJECT_PAT }}
project: https://github.com/orgs/github/projects/24263
---

Test workflow
`

err := os.WriteFile(mdFile, []byte(content), 0600)
require.NoError(t, err, "Failed to write test workflow markdown")

compiler := NewCompiler()
err = compiler.CompileWorkflow(mdFile)
require.NoError(t, err, "Failed to compile workflow")

lockFile := stringutil.MarkdownToLockFile(mdFile)
compiledBytes, err := os.ReadFile(lockFile)
require.NoError(t, err, "Failed to read compiled workflow")
compiled := string(compiledBytes)

assert.Contains(t, compiled, "WRITE_PROJECT_PAT: ${{ secrets.WRITE_PROJECT_PAT }}",
"Generate Safe Outputs Config step should map secret expressions to env vars")
assert.Equal(t, 2, strings.Count(compiled, "WRITE_PROJECT_PAT: ${{ secrets.WRITE_PROJECT_PAT }}"),
"Secret env vars should be available both when writing config.json and when starting the MCP server")
assert.Contains(t, compiled, `"github-token":"${WRITE_PROJECT_PAT}"`,
"config.json payload should preserve the secret placeholder instead of the secret value")

quotedHeredocPattern := regexp.MustCompile(`cat > "\$\{RUNNER_TEMP\}/gh-aw/safeoutputs/config\.json" << 'GH_AW_SAFE_OUTPUTS_CONFIG_[0-9a-f]{16}_EOF'`)
assert.True(t, quotedHeredocPattern.MatchString(compiled),
"Safe outputs config heredoc should be single-quoted so secret placeholders are not expanded onto disk")

unquotedHeredocPattern := regexp.MustCompile(`cat > "\$\{RUNNER_TEMP\}/gh-aw/safeoutputs/config\.json" << GH_AW_SAFE_OUTPUTS_CONFIG_[0-9a-f]{16}_EOF`)
assert.False(t, unquotedHeredocPattern.MatchString(compiled),
"Safe outputs config heredoc should not be unquoted when secrets are present")
}