Skip to content
52 changes: 44 additions & 8 deletions .github/workflows/copilot-pr-nlp-analysis.md
Original file line number Diff line number Diff line change
Expand Up @@ -206,16 +206,52 @@ For each generated chart:
find /tmp/gh-aw/python/charts/ -maxdepth 1 -ls
```

2. **Upload each chart** using the `upload asset` tool
3. **Collect returned URLs** for embedding in the discussion
2. **Upload each chart** using the `upload asset` MCP tool (call it directly — do NOT wrap in a shell command or use `$()` to capture the URL)

3. **Record the returned URL** from each upload by writing it to a plain text file in `/tmp/gh-aw/agent/` immediately after the MCP tool returns:
- `sentiment_distribution.png` → write URL to `/tmp/gh-aw/agent/url-sentiment-distribution.txt`
- `sentiment_timeline.png` → write URL to `/tmp/gh-aw/agent/url-sentiment-timeline.txt`
- `topic_frequencies.png` → write URL to `/tmp/gh-aw/agent/url-topic-frequencies.txt`
- `topics_wordcloud.png` → write URL to `/tmp/gh-aw/agent/url-topics-wordcloud.txt`
- `keyword_trends.png` → write URL to `/tmp/gh-aw/agent/url-keyword-trends.txt`

For example, after the `upload asset` tool returns `https://github.com/.../chart.png`, write it with:
```bash
echo -n "https://github.com/.../chart.png" > /tmp/gh-aw/agent/url-sentiment-distribution.txt
```

**Do NOT** store URLs in shell variables or use command substitution (`$(...)`) — this triggers the security harness.

### Phase 6: Create Analysis Discussion

Build the discussion body by reading the URL files saved in Phase 5 using Python, then post a comprehensive discussion.

**Before constructing the body**, use a Python script to read the uploaded chart URLs directly from the files (do not use shell variables or command substitution — read the files entirely within Python and treat missing files as empty strings):

```python
import os

def read_url(path):
try:
with open(path) as f:
return f.read().strip()
except FileNotFoundError:
return ""

sentiment_dist_url = read_url("/tmp/gh-aw/agent/url-sentiment-distribution.txt")
sentiment_time_url = read_url("/tmp/gh-aw/agent/url-sentiment-timeline.txt")
topic_freq_url = read_url("/tmp/gh-aw/agent/url-topic-frequencies.txt")
topics_cloud_url = read_url("/tmp/gh-aw/agent/url-topics-wordcloud.txt")
keyword_trends_url = read_url("/tmp/gh-aw/agent/url-keyword-trends.txt")
```
Comment on lines +209 to +246

Copilot AI Apr 29, 2026

Copy link

Choose a reason for hiding this comment

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

Phase 5 explicitly says not to store URLs in shell variables or use command substitution ($(...)), but Phase 6 immediately uses SENTIMENT_DIST_URL=$(cat ...) (both a shell variable and command substitution). If the intent is "don’t do this inside safe-outputs.steps run scripts" but it’s OK in the agent bash turn, please clarify the wording in Phase 5 (or adjust Phase 6 to avoid $()/shell vars by reading the files directly in Python) to remove the internal contradiction.

See below for a potential fix:

**Before constructing the body**, use Python to read the uploaded chart URLs directly from the files saved in `/tmp/gh-aw/agent/`. Do not use shell variables or command substitution here either; have Python read each file and treat missing files as empty strings.

Use a Python script to read:
- `/tmp/gh-aw/agent/url-sentiment-distribution.txt`
- `/tmp/gh-aw/agent/url-sentiment-timeline.txt`
- `/tmp/gh-aw/agent/url-topic-frequencies.txt`
- `/tmp/gh-aw/agent/url-topics-wordcloud.txt`
- `/tmp/gh-aw/agent/url-keyword-trends.txt`

Then write the fully-substituted discussion body to `/tmp/gh-aw/agent/discussion_body.md`, inserting the literal URL strings directly into the markdown body. After that, pass the body to the `create_discussion` safe-output tool.

Post a comprehensive discussion with the following structure:

**Title**: `Copilot PR Conversation NLP Analysis - [DATE]`

**Content Template** (substitute `[SENTIMENT_DIST_URL]`, `[SENTIMENT_TIME_URL]`, `[TOPIC_FREQ_URL]`, `[TOPICS_CLOUD_URL]`, and `[KEYWORD_TRENDS_URL]` with the literal URL strings read by Python from the files above):

Copilot uses AI. Check for mistakes.

Use this same Python script to write the fully-substituted discussion body to `/tmp/gh-aw/agent/discussion_body.md`, inserting the literal URL strings directly. Then pass the body to the `create_discussion` safe-output tool.

Post a comprehensive discussion with the following structure:

**Title**: `Copilot PR Conversation NLP Analysis - [DATE]`

**Content Template**:
**Content Template** (substitute `[SENTIMENT_DIST_URL]`, `[SENTIMENT_TIME_URL]`, `[TOPIC_FREQ_URL]`, `[TOPICS_CLOUD_URL]`, and `[KEYWORD_TRENDS_URL]` with the literal URL strings read by Python from the files above):
````markdown
# 🤖 Copilot PR Conversation NLP Analysis - [DATE]

Expand All @@ -230,7 +266,7 @@ Post a comprehensive discussion with the following structure:
## Sentiment Analysis

### Overall Sentiment Distribution
![Sentiment Distribution](URL_FROM_UPLOAD_ASSET_sentiment_distribution)
![Sentiment Distribution]([SENTIMENT_DIST_URL])

**Key Findings**:
- **Positive messages**: [count] ([percentage]%)
Expand All @@ -239,7 +275,7 @@ Post a comprehensive discussion with the following structure:
- **Average polarity**: [score] on scale of -1 (very negative) to +1 (very positive)

### Sentiment Over Conversation Timeline
![Sentiment Timeline](URL_FROM_UPLOAD_ASSET_sentiment_timeline)
![Sentiment Timeline]([SENTIMENT_TIME_URL])

**Observations**:
- [e.g., "Conversations typically start neutral and become more positive as issues are resolved"]
Expand All @@ -248,7 +284,7 @@ Post a comprehensive discussion with the following structure:
## Topic Analysis

### Identified Discussion Topics
![Topic Frequencies](URL_FROM_UPLOAD_ASSET_topic_frequencies)
![Topic Frequencies]([TOPIC_FREQ_URL])

**Major Topics Detected**:
1. **[Topic 1 Name]** ([count] messages, [percentage]%): [brief description]
Expand All @@ -257,12 +293,12 @@ Post a comprehensive discussion with the following structure:
4. **[Topic 4 Name]** ([count] messages, [percentage]%): [brief description]

### Topic Word Cloud
![Topics Word Cloud](URL_FROM_UPLOAD_ASSET_topics_wordcloud)
![Topics Word Cloud]([TOPICS_CLOUD_URL])

## Keyword Trends

### Most Common Keywords and Phrases
![Keyword Trends](URL_FROM_UPLOAD_ASSET_keyword_trends)
![Keyword Trends]([KEYWORD_TRENDS_URL])

**Top Recurring Terms**:
- **Technical**: [list top 5 technical terms]
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
# ADR-29123: Compile-Time Validation for Dangerous Shell Expansion in safe-outputs Steps

**Date**: 2026-04-29
**Status**: Draft
**Deciders**: pelikhan

---

## Part 1 — Narrative (Human-Friendly)

### Context

The safe-outputs security harness blocks a set of dangerous bash expansion patterns at runtime — specifically `${var@operator}` (parameter transformation), `${!var}` (indirect expansion), `$(...)` (command substitution), and backtick substitution — to prevent shell injection attacks in GitHub Actions steps that post results to GitHub APIs. Prior to this change, workflow authors had no compile-time feedback when their `run:` scripts contained these patterns; the first signal was a confusing runtime failure when the harness silently rejected the step. The Copilot PR NLP Analysis workflow exposed this gap when it used `$(upload_asset ...)` command substitution to capture chart upload URLs inside a safe-outputs step.

### Decision

We will add a compile-time validator (`validateSafeOutputsStepsShellExpansion`) that scans every `run:` script in `safe-outputs.steps[]` for the four blocked shell expansion patterns and fails compilation with a descriptive error before the workflow reaches runtime. This closes the feedback loop between the runtime harness rules and the authoring experience, making violations detectable and actionable during `gh aw compile` instead of at execution time.

### Alternatives Considered

#### Alternative 1: Improved Runtime Error Messages Only

The runtime harness already blocked these patterns; the simplest fix was to enrich the runtime error message with remediation guidance rather than adding a compile step. This was not chosen because it leaves the feedback loop long: an author must deploy and run the workflow before seeing the error, and the agent that runs the workflow may have already produced non-idempotent side effects (uploaded assets, posted partial comments) before the step fails.

#### Alternative 2: Allowlist Specific Patterns in the safe-outputs Harness

An alternative was to relax the harness to permit specific safe uses of `$(...)` (e.g., `$(cat /tmp/file.txt)`) rather than blocking all command substitution. This was not chosen because it significantly increases the attack surface of the harness — distinguishing safe from unsafe command substitutions reliably requires a full shell parser — and the pattern of writing intermediate values to files and reading them with `cat` in the safe-outputs step is a viable and already-supported escape hatch.

### Consequences

#### Positive
- Workflow authors receive an actionable compiler error with the offending snippet and remediation guidance, instead of a cryptic runtime failure.
- The compiler and the runtime harness stay in sync: any new pattern blocked by the harness can be added to the validator in the same change.

#### Negative
- The validator must be maintained in tandem with the runtime harness. If the harness gains or removes a blocked pattern without updating the validator, they drift out of sync.
- The regex-based approach requires post-match filtering for false positives (arithmetic `$((`, GitHub Actions `${{ }}` expressions) because Go's RE2 engine does not support lookaheads. Edge cases in the filtering logic can yield false positives or false negatives.

#### Neutral
- The validator is registered in `compiler_validators.go` after `validateSafeOutputsMax`, making it part of the standard compilation pipeline with no change to the CLI interface.
- The companion fix to the Copilot PR NLP Analysis prompt (writing URLs to files instead of using `$(...)`) demonstrates the recommended pattern that workflow authors should follow.

---

## Part 2 — Normative Specification (RFC 2119)

> The key words **MUST**, **MUST NOT**, **REQUIRED**, **SHALL**, **SHALL NOT**, **SHOULD**, **SHOULD NOT**, **RECOMMENDED**, **MAY**, and **OPTIONAL** in this section are to be interpreted as described in [RFC 2119](https://www.rfc-editor.org/rfc/rfc2119).

### Shell Expansion Validation

1. The compiler **MUST** validate every `run:` script in `safe-outputs.steps[]` for dangerous shell expansion patterns before emitting a compiled workflow artifact.
2. The compiler **MUST** reject any `run:` script that contains `${var@operator}` (bash parameter transformation), `${!var}` (indirect expansion), `$(...)` (command substitution), or `` `...` `` (backtick command substitution).
3. The compiler **MUST NOT** reject `run:` scripts that contain only `$VAR`, `${VAR}` (without operators), or `${{ expression }}` GitHub Actions template expressions.
4. The compiler **MUST NOT** reject `run:` scripts that contain `$((` arithmetic expansion.
5. Compiler error messages **MUST** include the zero-based step index, the offending snippet (truncated to 60 characters), and remediation guidance directing the author to write dynamic values to files in `/tmp/gh-aw/agent/` and read them with `cat` in the safe-outputs step.

### Safe-Outputs Step Authoring

1. Workflow authors **MUST NOT** use command substitution (`$(...)` or backticks) inside `safe-outputs.steps[].run` scripts to capture dynamic values.
2. Workflow authors **MUST NOT** use indirect expansion (`${!var}`) or parameter transformation (`${var@operator}`) in `safe-outputs.steps[].run` scripts.
3. When a safe-outputs step needs a value computed during the agent turn (e.g., an uploaded asset URL), the agent **MUST** write that value to a file in `/tmp/gh-aw/agent/` during its regular bash turn, and the safe-outputs step **MUST** read it using `cat` without shell expansion.
4. Workflow authors **SHOULD** use Python scripts to assemble complex multi-value payloads (e.g., discussion bodies) before passing them to a safe-outputs step, inserting literal strings rather than shell variables.

### Conformance

An implementation is considered conformant with this ADR if it satisfies all **MUST** and **MUST NOT** requirements above. Failure to meet any **MUST** or **MUST NOT** requirement constitutes non-conformance.

---

*This is a DRAFT ADR generated by the [Design Decision Gate](https://github.com/github/gh-aw/actions/runs/25114923934) workflow. The PR author must review, complete, and finalize this document before the PR can merge.*
12 changes: 12 additions & 0 deletions pkg/workflow/compiler_validators.go
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,18 @@ func (c *Compiler) validateToolConfiguration(workflowData *WorkflowData, markdow
return formatCompilerError(markdownPath, "error", err.Error(), err)
}

// Validate safe-outputs steps for dangerous shell expansion patterns.
// In strict mode this is a hard error; in non-strict mode it is a warning
// so that existing workflows continue to compile while authors migrate them.
log.Printf("Validating safe-outputs steps for shell expansion patterns")
if err := validateSafeOutputsStepsShellExpansion(workflowData.SafeOutputs); err != nil {
if c.strictMode {
return formatCompilerError(markdownPath, "error", err.Error(), err)
}
fmt.Fprintln(os.Stderr, formatCompilerMessage(markdownPath, "warning", err.Error()))
c.IncrementWarningCount()
}

// Validate safe-outputs allowed-domains configuration
log.Printf("Validating safe-outputs allowed-domains")
if err := c.validateSafeOutputsAllowedDomains(workflowData.SafeOutputs); err != nil {
Expand Down
195 changes: 195 additions & 0 deletions pkg/workflow/safe_outputs_steps_shell_expansion_validation.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,195 @@
// This file validates safe-outputs.steps run scripts for dangerous shell expansion
// patterns that would be blocked at runtime by the safe-outputs security harness.
//
// # Shell Expansion Security in Safe-Outputs Steps
//
// The safe-outputs security harness blocks shell scripts that contain dangerous
// bash expansion patterns. This validator detects those patterns at compile time
// so workflow authors receive a clear error during `gh aw compile` rather than
// a confusing runtime failure.
//
// # Blocked Patterns
//
// The following bash constructs are rejected:
// - ${var@operator}: bash 4.4+ parameter transformation (e.g. ${foo@P}, ${bar@U})
// - ${!var}: bash indirect expansion
// - $(command): command substitution
// - `command`: backtick command substitution
//
// GitHub Actions template expressions (${{ ... }}) are explicitly allowed and are
// excluded from the checks.
//
// # When to Add Validation Here
//
// Add validation to this file when:
// - A new dangerous shell expansion variant must be detected in safe-outputs run scripts
// - The safe-outputs harness blocks a new class of shell pattern at runtime
//
// For general safe-outputs validation, see safe_outputs_validation.go.

package workflow

import (
"fmt"
"regexp"
"strings"
)

var safeOutputsStepsShellExpansionLog = newValidationLogger("safe_outputs_steps_shell_expansion")

// shellExpansionPattern matches dangerous bash expansion constructs inside a run: script.
//
// Captured groups by name:
// - "paramTransform": ${var@operator} — bash 4.4+ parameter transformation
// - "indirectExpand": ${!var} — bash indirect expansion
// - "commandSubst": $(...) — command substitution (any $( sequence)
// - "backtick": `...` — backtick command substitution
//
// After matching, callers must exclude false-positives:
// - "commandSubst" matches starting with "$((" (arithmetic expansion) are ignored.
// - GitHub Actions expressions use "${{ ... }}", which starts with "{" not "(",
// so they do not match the "commandSubst" pattern (\$\() at all.
//
// Note: Go's regexp/RE2 does not support lookaheads, so post-match filtering is used
// instead of inline negative lookaheads.
var shellExpansionPattern = regexp.MustCompile(
// Parameter transformation: ${var@op} — the @ must follow word characters
`(?P<paramTransform>\$\{[A-Za-z_][A-Za-z0-9_]*@[^}]*\})` +
`|` +
// Indirect expansion: ${!var} — '!' immediately after '{'
`(?P<indirectExpand>\$\{![A-Za-z_])` +
`|` +
// Command substitution: any $( sequence
`(?P<commandSubst>\$\()` +
`|` +
// Backtick command substitution: `...`
"(?P<backtick>`[^`\n]+`)",
)

// dangerousPatternDescription maps a named capture group to a human-readable
// description used in compiler error messages.
var dangerousPatternDescription = map[string]string{
"paramTransform": "parameter transformation (e.g. ${var@P})",
"indirectExpand": "indirect expansion (e.g. ${!var})",
"commandSubst": "command substitution (e.g. $(command))",
"backtick": "backtick command substitution (e.g. `command`)",
}

// validateSafeOutputsStepsShellExpansion checks every run: script in the
// safe-outputs.steps list for dangerous bash expansion patterns that would be
// blocked by the safe-outputs security harness at runtime.
//
// Returning a non-nil error causes compilation to fail with a descriptive message
// that includes the step index, the offending snippet, and the pattern category.
func validateSafeOutputsStepsShellExpansion(config *SafeOutputsConfig) error {
if config == nil || len(config.Steps) == 0 {
return nil
}

safeOutputsStepsShellExpansionLog.Printf("Validating %d safe-outputs steps for dangerous shell expansion patterns", len(config.Steps))

for i, step := range config.Steps {
stepMap, ok := step.(map[string]any)
if !ok {
continue
}
runVal, exists := stepMap["run"]
if !exists {
continue
}
runScript, ok := runVal.(string)
if !ok {
continue
}

if err := validateRunScriptForShellExpansion(i, runScript); err != nil {
return err
}
}

safeOutputsStepsShellExpansionLog.Print("Safe-outputs steps shell expansion validation passed")
return nil
}

// validateRunScriptForShellExpansion checks a single run: script for dangerous
// bash expansion patterns. stepIndex is 0-based and is included in error messages.
func validateRunScriptForShellExpansion(stepIndex int, script string) error {
// Fast path: no '$' or backtick character means no expansion pattern can be present.
if !strings.ContainsAny(script, "$`") {
return nil
}

// Scan all matches so we can skip false positives (e.g. arithmetic $((, GitHub
// Actions ${{ expressions) before reporting the first true violation.
groupNames := shellExpansionPattern.SubexpNames()
allMatches := shellExpansionPattern.FindAllStringSubmatchIndex(script, -1)

for _, match := range allMatches {
snippet := ""
patternDescription := "dangerous shell expansion"
groupName := ""
matchStart := match[0]

for gi, name := range groupNames {
if name == "" {
continue
}
start, end := match[gi*2], match[gi*2+1]
if start < 0 {
continue // group did not participate in this match
}
if _, known := dangerousPatternDescription[name]; known {
raw := script[start:end]
groupName = name
// For short patterns like $( or ${! that don't capture the full construct,
// extend the snippet to include context up to the end of the line or 60 chars.
if len(raw) < 10 {
lineEnd := strings.IndexByte(script[start:], '\n')
if lineEnd < 0 {
lineEnd = len(script) - start
}
raw = script[start : start+lineEnd]
// Remove any trailing control characters.
raw = strings.TrimRight(raw, "\r\t ")
}
// Clip the snippet to 60 characters to keep the error readable.
if len(raw) > 60 {
raw = raw[:57] + "..."
}
snippet = raw
patternDescription = dangerousPatternDescription[name]
break
}
}

if groupName == "" {
continue
}

// Skip arithmetic expansion $((: it uses $(( not $(command).
if groupName == "commandSubst" {
// Check the two characters after $( to detect $((
if matchStart+3 <= len(script) && script[matchStart:matchStart+3] == "$((" {
continue
}
}

safeOutputsStepsShellExpansionLog.Printf("Dangerous pattern found in safe-outputs step %d: %s", stepIndex, patternDescription)

return fmt.Errorf(
"safe-outputs.steps[%d]: run script contains %s, which is blocked by the "+
"safe-outputs security harness at runtime\n\n"+
" Offending snippet: %s\n\n"+
"Avoid command substitution, backticks, indirect expansion, and parameter "+
"transformation in safe-outputs run scripts. Write URL values and other "+
"dynamic content to files in /tmp/gh-aw/agent/ during the agent turn, then "+
"read the file contents in the safe-outputs step (e.g. with 'cat' or by "+
"passing a script argument)",
stepIndex,
patternDescription,
snippet,
)
}

return nil
}
Loading