diff --git a/.github/workflows/test-claude.lock.yml b/.github/workflows/test-claude.lock.yml index 8a4fa0631c..0bee345886 100644 --- a/.github/workflows/test-claude.lock.yml +++ b/.github/workflows/test-claude.lock.yml @@ -102,12 +102,6 @@ jobs: 3. **Generate Summary Report** Create a detailed comment on the pull request with the following sections: - - #### 🤖 Claude PR Summary - - **Branch:** `${{ github.head_ref }}` - **Files Changed:** [number] files - **Analysis Time:** [current timestamp from get_current_time] #### 📋 Change Overview - Brief description of what this PR accomplishes @@ -136,7 +130,7 @@ jobs: - Reference to documentation updates needed --- - *Generated by Claude AI on ${{ github.event.pull_request.created_at }}* + *Generated by Claude AI* ### Instructions diff --git a/.github/workflows/test-claude.md b/.github/workflows/test-claude.md index afa826453a..959c190179 100644 --- a/.github/workflows/test-claude.md +++ b/.github/workflows/test-claude.md @@ -46,12 +46,6 @@ You are a code review assistant powered by Claude. Your task is to analyze the c 3. **Generate Summary Report** Create a detailed comment on the pull request with the following sections: - - #### 🤖 Claude PR Summary - - **Branch:** `${{ github.head_ref }}` - **Files Changed:** [number] files - **Analysis Time:** [current timestamp from get_current_time] #### 📋 Change Overview - Brief description of what this PR accomplishes @@ -80,7 +74,7 @@ You are a code review assistant powered by Claude. Your task is to analyze the c - Reference to documentation updates needed --- - *Generated by Claude AI on ${{ github.event.pull_request.created_at }}* + *Generated by Claude AI* ### Instructions diff --git a/.github/workflows/test-codex.lock.yml b/.github/workflows/test-codex.lock.yml index dc729baaa4..e0daa4de5e 100644 --- a/.github/workflows/test-codex.lock.yml +++ b/.github/workflows/test-codex.lock.yml @@ -102,12 +102,6 @@ jobs: 3. **Generate Summary Report** Create a detailed comment on the pull request with the following sections: - - #### 🤖 Codex PR Summary - - **Branch:** `${{ github.head_ref }}` - **Files Changed:** [number] files - **Analysis Time:** [current timestamp from get_current_time] #### 📋 Change Overview - Brief description of what this PR accomplishes @@ -136,7 +130,7 @@ jobs: - Reference to documentation updates needed --- - *Generated by Codex AI on ${{ github.event.pull_request.created_at }}* + *Generated by Codex AI* ### Instructions diff --git a/.github/workflows/test-codex.md b/.github/workflows/test-codex.md index 14477ad305..a0ab0c5c76 100644 --- a/.github/workflows/test-codex.md +++ b/.github/workflows/test-codex.md @@ -46,12 +46,6 @@ You are a code review assistant powered by Codex. Your task is to analyze the ch 3. **Generate Summary Report** Create a detailed comment on the pull request with the following sections: - - #### 🤖 Codex PR Summary - - **Branch:** `${{ github.head_ref }}` - **Files Changed:** [number] files - **Analysis Time:** [current timestamp from get_current_time] #### 📋 Change Overview - Brief description of what this PR accomplishes @@ -80,7 +74,7 @@ You are a code review assistant powered by Codex. Your task is to analyze the ch - Reference to documentation updates needed --- - *Generated by Codex AI on ${{ github.event.pull_request.created_at }}* + *Generated by Codex AI* ### Instructions diff --git a/.github/workflows/weekly-research.lock.yml b/.github/workflows/weekly-research.lock.yml index 808d7ac117..3ba902f0d9 100644 --- a/.github/workflows/weekly-research.lock.yml +++ b/.github/workflows/weekly-research.lock.yml @@ -72,7 +72,7 @@ jobs: ## Job Description - Do a deep research investigation in ${{ env.GITHUB_REPOSITORY }} repository, and the related industry in general. + Do a deep research investigation in ${{ github.repository }} repository, and the related industry in general. - Read selections of the latest code, issues and PRs for this repo. - Read latest trends and news from the software industry news source on the Web. diff --git a/.github/workflows/weekly-research.md b/.github/workflows/weekly-research.md index e85ddeeefd..d161f60ac8 100644 --- a/.github/workflows/weekly-research.md +++ b/.github/workflows/weekly-research.md @@ -29,7 +29,7 @@ tools: ## Job Description -Do a deep research investigation in ${{ env.GITHUB_REPOSITORY }} repository, and the related industry in general. +Do a deep research investigation in ${{ github.repository }} repository, and the related industry in general. - Read selections of the latest code, issues and PRs for this repo. - Read latest trends and news from the software industry news source on the Web. diff --git a/docs/index.md b/docs/index.md index 43f773fb5a..b9d0cf1636 100644 --- a/docs/index.md +++ b/docs/index.md @@ -7,7 +7,7 @@ Complete documentation for creating and managing agentic workflows with GitHub A ## Getting Started -- **[Workflow Structure](workflow-structure.md)** - Directory layout and file organization +- **[Workflow Structure](workflow-structure.md)** - Directory layout, file organization, and expression security - **[Commands](commands.md)** - CLI commands for workflow management - **[Include Directives](include-directives.md)** - Modularizing workflows with includes diff --git a/docs/workflow-structure.md b/docs/workflow-structure.md index 059ed044e8..23b1c20b4a 100644 --- a/docs/workflow-structure.md +++ b/docs/workflow-structure.md @@ -95,6 +95,91 @@ When a new issue is opened, analyze the issue content and: The issue details are: "${{ needs.task.outputs.text }}" ``` +## Expression Security + +For security reasons, agentic workflows restrict which GitHub Actions expressions can be used in **markdown content**. This prevents potential security vulnerabilities from unauthorized access to secrets or environment variables. + +> **Note**: These restrictions apply only to expressions in the markdown content portion of workflows. The YAML frontmatter can still use secrets and environment variables as needed for workflow configuration (e.g., `env:` and authentication). + +### Allowed Expressions + +The following GitHub Actions context expressions are permitted in workflow markdown: +#### GitHub Context Expressions + +- `${{ github.event.after }}` - The SHA of the most recent commit on the ref after the push +- `${{ github.event.before }}` - The SHA of the most recent commit on the ref before the push +- `${{ github.event.check_run.id }}` - The ID of the check run that triggered the workflow +- `${{ github.event.check_suite.id }}` - The ID of the check suite that triggered the workflow +- `${{ github.event.comment.id }}` - The ID of the comment that triggered the workflow +- `${{ github.event.deployment.id }}` - The ID of the deployment that triggered the workflow +- `${{ github.event.deployment_status.id }}` - The ID of the deployment status that triggered the workflow +- `${{ github.event.head_commit.id }}` - The ID of the head commit for the push event +- `${{ github.event.installation.id }}` - The ID of the GitHub App installation +- `${{ github.event.issue.number }}` - The number of the issue that triggered the workflow +- `${{ github.event.label.id }}` - The ID of the label that triggered the workflow +- `${{ github.event.milestone.id }}` - The ID of the milestone that triggered the workflow +- `${{ github.event.organization.id }}` - The ID of the organization that triggered the workflow +- `${{ github.event.page.id }}` - The ID of the page build that triggered the workflow +- `${{ github.event.project.id }}` - The ID of the project that triggered the workflow +- `${{ github.event.project_card.id }}` - The ID of the project card that triggered the workflow +- `${{ github.event.project_column.id }}` - The ID of the project column that triggered the workflow +- `${{ github.event.pull_request.number }}` - The number of the pull request that triggered the workflow +- `${{ github.event.release.assets[0].id }}` - The ID of the first asset in a release +- `${{ github.event.release.id }}` - The ID of the release that triggered the workflow +- `${{ github.event.repository.id }}` - The ID of the repository that triggered the workflow +- `${{ github.event.review.id }}` - The ID of the pull request review that triggered the workflow +- `${{ github.event.review_comment.id }}` - The ID of the review comment that triggered the workflow +- `${{ github.event.sender.id }}` - The ID of the user who triggered the workflow +- `${{ github.event.workflow_run.id }}` - The ID of the workflow run that triggered the current workflow +- `${{ github.actor }}` - The username of the user who triggered the workflow +- `${{ github.owner }}` - The owner of the repository (user or organization name) +- `${{ github.repository }}` - The owner and repository name (e.g., `octocat/Hello-World`) +- `${{ github.run_id }}` - A unique number for each workflow run within a repository +- `${{ github.run_number }}` - A unique number for each run of a particular workflow in a repository +- `${{ github.workflow }}` - The name of the workflow + +#### Special Pattern Expressions +- `${{ needs.* }}` - Any outputs from previous jobs (e.g., `${{ needs.task.outputs.text }}`) +- `${{ steps.* }}` - Any outputs from previous steps in the same job + +### Prohibited Expressions + +All other expressions are dissallowed. + +### Security Rationale + +This restriction prevents: +- **Secret leakage**: Prevents accidentally exposing secrets in AI prompts or logs +- **Environment variable exposure**: Protects sensitive configuration from being accessed +- **Code injection**: Prevents complex expressions that could execute unintended code +- **Expression injection**: Prevents malicious expressions from being injected into AI prompts +- **Prompt hijacking**: Stops unauthorized modification of workflow instructions through expression values +- **Cross-prompt information attacks (XPIA)**: Blocks attempts to leak information between different workflow executions + +### Validation + +Expression safety is validated during compilation with `gh aw compile`. If unauthorized expressions are found, you'll see an error like: + +``` +error: unauthorized expressions: [secrets.TOKEN, env.MY_VAR]. +allowed: [github.repository, github.actor, github.workflow, ...] +``` + +### Example Valid Usage + +```markdown +# Valid expressions +Repository: ${{ github.repository }} +Triggered by: ${{ github.actor }} +Issue number: ${{ github.event.issue.number }} +Previous output: ${{ needs.task.outputs.text }} + +# Invalid expressions (will cause compilation error) +Token: ${{ secrets.GITHUB_TOKEN }} +Environment: ${{ env.MY_VAR }} +Complex: ${{ toJson(github.workflow) }} +``` + ## Generated Files When you run `gh aw compile`, the system: diff --git a/pkg/cli/templates/instructions.md b/pkg/cli/templates/instructions.md index 034b42cf68..f67f03fecc 100644 --- a/pkg/cli/templates/instructions.md +++ b/pkg/cli/templates/instructions.md @@ -161,30 +161,64 @@ on: ## GitHub Context Expression Interpolation -Use GitHub Actions context expressions throughout the workflow content: - -### Common Context Variables +Use GitHub Actions context expressions throughout the workflow content. **Note: For security reasons, only specific expressions are allowed.** + +### Allowed Context Variables +- **`${{ github.event.after }}`** - SHA of the most recent commit after the push +- **`${{ github.event.before }}`** - SHA of the most recent commit before the push +- **`${{ github.event.check_run.id }}`** - ID of the check run +- **`${{ github.event.check_suite.id }}`** - ID of the check suite +- **`${{ github.event.comment.id }}`** - ID of the comment +- **`${{ github.event.deployment.id }}`** - ID of the deployment +- **`${{ github.event.deployment_status.id }}`** - ID of the deployment status +- **`${{ github.event.head_commit.id }}`** - ID of the head commit +- **`${{ github.event.installation.id }}`** - ID of the GitHub App installation - **`${{ github.event.issue.number }}`** - Issue number -- **`${{ github.event.issue.title }}`** - Issue title -- **`${{ github.event.issue.body }}`** - Issue body content -- **`${{ github.event.comment.body }}`** - Comment content -- **`${{ github.repository }}`** - Repository name (owner/repo) -- **`${{ github.actor }}`** - User who triggered the workflow -- **`${{ github.run_id }}`** - Workflow run ID -- **`${{ github.workflow }}`** - Workflow name -- **`${{ github.ref }}`** - Git reference -- **`${{ github.sha }}`** - Commit SHA - -### Environment Variables -- **`${{ env.GITHUB_REPOSITORY }}`** - Repository name -- **`${{ secrets.GITHUB_TOKEN }}`** - GitHub token -- **Custom variables**: `${{ env.CUSTOM_VAR }}` +- **`${{ github.event.label.id }}`** - ID of the label +- **`${{ github.event.milestone.id }}`** - ID of the milestone +- **`${{ github.event.organization.id }}`** - ID of the organization +- **`${{ github.event.page.id }}`** - ID of the GitHub Pages page +- **`${{ github.event.project.id }}`** - ID of the project +- **`${{ github.event.project_card.id }}`** - ID of the project card +- **`${{ github.event.project_column.id }}`** - ID of the project column +- **`${{ github.event.pull_request.number }}`** - Pull request number +- **`${{ github.event.release.assets[0].id }}`** - ID of the first release asset +- **`${{ github.event.release.id }}`** - ID of the release +- **`${{ github.event.repository.id }}`** - ID of the repository +- **`${{ github.event.review.id }}`** - ID of the review +- **`${{ github.event.review_comment.id }}`** - ID of the review comment +- **`${{ github.event.sender.id }}`** - ID of the user who triggered the event +- **`${{ github.event.workflow_run.id }}`** - ID of the workflow run +- **`${{ github.actor }}`** - Username of the person who initiated the workflow +- **`${{ github.owner }}`** - Owner of the repository +- **`${{ github.repository }}`** - Repository name in "owner/name" format +- **`${{ github.run_id }}`** - Unique ID of the workflow run +- **`${{ github.run_number }}`** - Number of the workflow run +- **`${{ github.workflow }}`** - Name of the workflow + +#### Special Pattern Expressions +- **`${{ needs.* }}`** - Any outputs from previous jobs (e.g., `${{ needs.task.outputs.text }}`) +- **`${{ steps.* }}`** - Any outputs from previous steps (e.g., `${{ steps.my-step.outputs.result }}`) + +All other expressions are dissallowed. + +### Security Validation + +Expression safety is automatically validated during compilation. If unauthorized expressions are found, compilation will fail with an error listing the prohibited expressions. ### Example Usage ```markdown +# Valid expressions Analyze issue #${{ github.event.issue.number }} in repository ${{ github.repository }}. The issue was created by ${{ github.actor }} with title: "${{ github.event.issue.title }}" + +Using output from previous task: "${{ needs.task.outputs.text }}" + +# Invalid expressions (will cause compilation errors) +# Token: ${{ secrets.GITHUB_TOKEN }} +# Environment: ${{ env.MY_VAR }} +# Complex: ${{ toJson(github.workflow) }} ``` ## Tool Configuration @@ -342,7 +376,7 @@ timeout_minutes: 15 # Weekly Research -Research latest developments in ${{ env.GITHUB_REPOSITORY }}: +Research latest developments in ${{ github.repository }}: - Review recent commits and issues - Search for industry trends - Create summary issue diff --git a/pkg/constants/constants.go b/pkg/constants/constants.go index 14d45647d6..0639bca373 100644 --- a/pkg/constants/constants.go +++ b/pkg/constants/constants.go @@ -2,3 +2,39 @@ package constants // CLIExtensionPrefix is the prefix used in user-facing output to refer to the CLI extension const CLIExtensionPrefix = "gh aw" + +// AllowedExpressions contains the GitHub Actions expressions that can be used in workflow markdown content +// see https://docs.github.com/en/actions/reference/workflows-and-actions/contexts#github-context +var AllowedExpressions = []string{ + "github.event.after", + "github.event.before", + "github.event.check_run.id", + "github.event.check_suite.id", + "github.event.comment.id", + "github.event.deployment.id", + "github.event.deployment_status.id", + "github.event.head_commit.id", + "github.event.installation.id", + "github.event.issue.number", + "github.event.label.id", + "github.event.milestone.id", + "github.event.organization.id", + "github.event.page.id", + "github.event.project.id", + "github.event.project_card.id", + "github.event.project_column.id", + "github.event.pull_request.number", + "github.event.release.assets[0].id", + "github.event.release.id", + "github.event.repository.id", + "github.event.review.id", + "github.event.review_comment.id", + "github.event.sender.id", + "github.event.workflow_run.id", + "github.actor", + "github.owner", + "github.repository", + "github.run_id", + "github.run_number", + "github.workflow", +} // needs., steps. already allowed diff --git a/pkg/workflow/compiler.go b/pkg/workflow/compiler.go index df3c3cf76f..e0a54fb333 100644 --- a/pkg/workflow/compiler.go +++ b/pkg/workflow/compiler.go @@ -8,6 +8,7 @@ import ( "net/http" "os" "path/filepath" + "regexp" "sort" "strings" "time" @@ -19,6 +20,63 @@ import ( "gopkg.in/yaml.v3" ) +// validateExpressionSafety checks that all GitHub Actions expressions in the markdown content +// are in the allowed list and returns an error if any unauthorized expressions are found +func validateExpressionSafety(markdownContent string) error { + // Regular expression to match GitHub Actions expressions: ${{ ... }} + // Use (?s) flag to enable dotall mode so . matches newlines to capture multiline expressions + // Use non-greedy matching with .*? to handle nested braces properly + expressionRegex := regexp.MustCompile(`(?s)\$\{\{(.*?)\}\}`) + needsStepsRegex := regexp.MustCompile(`^(needs|steps)\.[a-zA-Z0-9_-]+(\.[a-zA-Z0-9_-]+)*$`) + + // Find all expressions in the markdown content + matches := expressionRegex.FindAllStringSubmatch(markdownContent, -1) + + var unauthorizedExpressions []string + + for _, match := range matches { + if len(match) < 2 { + continue + } + + // Extract the expression content (everything between ${{ and }}) + expression := strings.TrimSpace(match[1]) + + // Reject expressions that span multiple lines (contain newlines) + if strings.Contains(match[1], "\n") { + unauthorizedExpressions = append(unauthorizedExpressions, expression) + continue + } + + // Check if this expression is in the allowed list + allowed := false + + // Check if this expression starts with "needs." or "steps." and is a simple property access + if needsStepsRegex.MatchString(expression) { + allowed = true + } else { + for _, allowedExpr := range constants.AllowedExpressions { + if expression == allowedExpr { + allowed = true + break + } + } + } + + if !allowed { + unauthorizedExpressions = append(unauthorizedExpressions, expression) + } + } + + // If we found unauthorized expressions, return an error + if len(unauthorizedExpressions) > 0 { + return fmt.Errorf("unauthorized expressions: %v. allowed: %v", + unauthorizedExpressions, constants.AllowedExpressions) + } + + return nil +} + // FileTracker interface for tracking files created during compilation type FileTracker interface { TrackCreated(filePath string) @@ -179,6 +237,26 @@ func (c *Compiler) CompileWorkflow(markdownPath string) error { return errors.New(formattedErr) } + // Validate expression safety - check that all GitHub Actions expressions are in the allowed list + if c.verbose { + fmt.Println(console.FormatInfoMessage("Validating expression safety...")) + } + if err := validateExpressionSafety(workflowData.MarkdownContent); err != nil { + formattedErr := console.FormatError(console.CompilerError{ + Position: console.ErrorPosition{ + File: markdownPath, + Line: 1, + Column: 1, + }, + Type: "error", + Message: err.Error(), + }) + return errors.New(formattedErr) + } + if c.verbose { + fmt.Println(console.FormatSuccessMessage("Expression safety validation passed")) + } + if c.verbose { fmt.Println(console.FormatSuccessMessage("Successfully parsed frontmatter and markdown content")) fmt.Println(console.FormatInfoMessage(fmt.Sprintf("Workflow name: %s", workflowData.Name))) diff --git a/pkg/workflow/expression_safety_test.go b/pkg/workflow/expression_safety_test.go new file mode 100644 index 0000000000..cc0788c353 --- /dev/null +++ b/pkg/workflow/expression_safety_test.go @@ -0,0 +1,193 @@ +package workflow + +import ( + "strings" + "testing" +) + +func TestValidateExpressionSafety(t *testing.T) { + tests := []struct { + name string + content string + expectError bool + expectedErrors []string + }{ + { + name: "no_expressions", + content: "This is a simple markdown with no expressions", + expectError: false, + }, + { + name: "allowed_github_workflow", + content: "The workflow name is ${{ github.workflow }}", + expectError: false, + }, + { + name: "allowed_github_repository", + content: "Repository: ${{ github.repository }}", + expectError: false, + }, + { + name: "allowed_github_run_id", + content: "Run ID: ${{ github.run_id }}", + expectError: false, + }, + { + name: "allowed_github_event_issue_number", + content: "Issue number: ${{ github.event.issue.number }}", + expectError: false, + }, + { + name: "allowed_needs_task_outputs_text", + content: "Task output: ${{ needs.task.outputs.text }}", + expectError: false, + }, + { + name: "multiple_allowed_expressions", + content: "Workflow: ${{ github.workflow }}, Repository: ${{ github.repository }}, Output: ${{ needs.task.outputs.text }}", + expectError: false, + }, + { + name: "unauthorized_github_token", + content: "Using token: ${{ secrets.GITHUB_TOKEN }}", + expectError: true, + expectedErrors: []string{"secrets.GITHUB_TOKEN"}, + }, + { + name: "authorized_github_actor", + content: "Actor: ${{ github.actor }}", + expectError: false, + }, + { + name: "unauthorized_env_variable", + content: "Environment: ${{ env.MY_VAR }}", + expectError: true, + expectedErrors: []string{"env.MY_VAR"}, + }, + { + name: "unauthorized_steps_output", + content: "Step output: ${{ steps.my-step.outputs.result }}", + expectError: false, + // Note: steps outputs are allowed, but this is a test case to ensure it + expectedErrors: []string{"steps.my-step.outputs.result"}, + }, + { + name: "mixed_authorized_and_unauthorized", + content: "Valid: ${{ github.workflow }}, Invalid: ${{ secrets.API_KEY }}", + expectError: true, + expectedErrors: []string{"secrets.API_KEY"}, + }, + { + name: "multiple_unauthorized_expressions", + content: "Token: ${{ secrets.GITHUB_TOKEN }}, Valid: ${{ github.actor }}, Env: ${{ env.TEST }}", + expectError: true, + expectedErrors: []string{"secrets.GITHUB_TOKEN", "env.TEST"}, + }, + { + name: "expressions_with_whitespace", + content: "Spaced: ${{ github.workflow }}, Normal: ${{github.repository}}", + expectError: false, + }, + { + name: "expressions_with_unauthorized_whitespace", + content: "Invalid spaced: ${{ secrets.TOKEN }}", + expectError: true, + expectedErrors: []string{"secrets.TOKEN"}, + }, + { + name: "expressions_in_code_blocks", + content: "Code example: `${{ github.workflow }}` and ```${{ github.repository }}```", + expectError: false, + }, + { + name: "unauthorized_in_code_blocks", + content: "Code example: `${{ secrets.TOKEN }}` should still be caught", + expectError: true, + expectedErrors: []string{"secrets.TOKEN"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := validateExpressionSafety(tt.content) + + if tt.expectError && err == nil { + t.Errorf("Expected error but got none") + } + + if !tt.expectError && err != nil { + t.Errorf("Expected no error but got: %v", err) + } + + if tt.expectError && err != nil { + // Check that all expected unauthorized expressions are mentioned in the error + errorMsg := err.Error() + for _, expectedError := range tt.expectedErrors { + if !strings.Contains(errorMsg, expectedError) { + t.Errorf("Expected error message to contain '%s', but got: %s", expectedError, errorMsg) + } + } + } + }) + } +} + +func TestValidateExpressionSafetyEdgeCases(t *testing.T) { + tests := []struct { + name string + content string + expectError bool + description string + }{ + { + name: "empty_expression", + content: "Empty: ${{ }}", + expectError: true, + description: "Empty expressions should be considered unauthorized", + }, + { + name: "malformed_expression_single_brace", + content: "Malformed: ${ github.workflow }", + expectError: false, + description: "Malformed expressions (single brace) should be ignored", + }, + { + name: "malformed_expression_no_closing", + content: "Malformed: ${{ github.workflow", + expectError: false, + description: "Malformed expressions (no closing) should be ignored", + }, + { + name: "nested_expressions", + content: "Nested: ${{ ${{ github.workflow }} }}", + expectError: true, + description: "Nested expressions should be caught", + }, + { + name: "expression_with_functions", + content: "Function: ${{ toJson(github.workflow) }}", + expectError: true, + description: "Expressions with functions should be unauthorized unless the base expression is allowed", + }, + { + name: "multiline_expression", + content: "Multi:\n${{ github.workflow\n}}", + expectError: true, + description: "Should NOT handle expressions spanning multiple lines - though this is unusual", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := validateExpressionSafety(tt.content) + + if tt.expectError && err == nil { + t.Errorf("Expected error for %s but got none", tt.description) + } + + if !tt.expectError && err != nil { + t.Errorf("Expected no error for %s but got: %v", tt.description, err) + } + }) + } +}