From 8672603816623d57adbdf6101483aca6e8de0ada Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 23 Apr 2026 12:05:22 +0000 Subject: [PATCH 1/4] Initial plan From 17e4d460e59b175727700802a04a6002d1a4b84c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 23 Apr 2026 12:22:51 +0000 Subject: [PATCH 2/4] security: reject disable-xpia-prompt combined with bash tool access Agent-Logs-Url: https://github.com/github/gh-aw/sessions/efedb545-e8e5-4f6a-8028-a53bfe718ef5 Co-authored-by: szabta89 <1330202+szabta89@users.noreply.github.com> --- pkg/workflow/features_validation.go | 40 +++++++ pkg/workflow/features_validation_test.go | 133 +++++++++++++++++++++++ 2 files changed, 173 insertions(+) diff --git a/pkg/workflow/features_validation.go b/pkg/workflow/features_validation.go index b38ceb4a7a7..090b6be7fc5 100644 --- a/pkg/workflow/features_validation.go +++ b/pkg/workflow/features_validation.go @@ -5,12 +5,14 @@ // This file validates feature flag values to ensure they meet requirements // before being used in workflow compilation. It ensures that: // - action-tag uses a full 40-character SHA or a version tag when specified +// - disable-xpia-prompt is not combined with bash tool access (supply-chain attack vector) // - Other feature-specific constraints are met // // # Validation Functions // // - validateFeatures() - Validates all feature flags in WorkflowData // - validateActionTag() - Validates action-tag is a full SHA or version tag +// - validateDisableXPIAWithBash() - Rejects disable-xpia-prompt combined with bash tools // - isValidFullSHA() - Checks if a string is a valid 40-character SHA // - semverutil.IsActionVersionTag() - Checks if a string is a valid version tag (in pkg/semverutil) // @@ -26,6 +28,7 @@ package workflow import ( "fmt" + "github.com/github/gh-aw/pkg/constants" "github.com/github/gh-aw/pkg/gitutil" "github.com/github/gh-aw/pkg/semverutil" ) @@ -51,10 +54,47 @@ func validateFeatures(data *WorkflowData) error { featuresValidationLog.Print("Action-tag validation passed") } + // Validate that disable-xpia-prompt is not combined with bash tool access + if isFeatureEnabled(constants.DisableXPIAPromptFeatureFlag, data) { + featuresValidationLog.Print("Validating disable-xpia-prompt combination") + if err := validateDisableXPIAWithBash(data); err != nil { + featuresValidationLog.Printf("disable-xpia-prompt combination validation failed: %v", err) + return err + } + featuresValidationLog.Print("disable-xpia-prompt combination validation passed") + } + featuresValidationLog.Print("Features validation completed successfully") return nil } +// validateDisableXPIAWithBash rejects the dangerous combination of disable-xpia-prompt: true +// and bash tool access. When XPIA protection is disabled, the agent has no framing to +// distinguish adversarial instructions from legitimate ones. Combined with bash tool access, +// a prompt-injection payload can trivially escalate to arbitrary shell command execution +// (e.g. npm install of a malicious package with lifecycle scripts). +func validateDisableXPIAWithBash(data *WorkflowData) error { + if data == nil || data.ParsedTools == nil { + return nil + } + + if data.ParsedTools.Bash == nil { + return nil + } + + return NewValidationError( + "features.disable-xpia-prompt", + "true", + "disable-xpia-prompt cannot be combined with bash tool access. "+ + "Disabling XPIA protection removes the primary defense against prompt-injection attacks. "+ + "When combined with bash tool access, a prompt-injection payload can escalate to "+ + "arbitrary shell command execution (e.g. npm install of an attacker-controlled package).", + "Either re-enable XPIA protection by removing the disable-xpia-prompt feature flag, "+ + "or remove the bash tool from the workflow's tool configuration.\n"+ + "If shell access is required, keep XPIA protection enabled (omit disable-xpia-prompt or set it to false).", + ) +} + // validateActionTag validates that action-tag is a full 40-character SHA or a version tag when specified func validateActionTag(value any) error { // Allow empty or nil values diff --git a/pkg/workflow/features_validation_test.go b/pkg/workflow/features_validation_test.go index 633ed11d486..6df1c873849 100644 --- a/pkg/workflow/features_validation_test.go +++ b/pkg/workflow/features_validation_test.go @@ -226,6 +226,66 @@ func TestValidateFeatures(t *testing.T) { }, expectError: false, }, + { + name: "disable-xpia-prompt with no bash tool - allowed", + data: &WorkflowData{ + Features: map[string]any{ + "disable-xpia-prompt": true, + }, + ParsedTools: NewTools(map[string]any{ + "github": map[string]any{}, + }), + }, + expectError: false, + }, + { + name: "disable-xpia-prompt with bash tool - rejected", + data: &WorkflowData{ + Features: map[string]any{ + "disable-xpia-prompt": true, + }, + ParsedTools: NewTools(map[string]any{ + "bash": true, + }), + }, + expectError: true, + errorMsg: "disable-xpia-prompt cannot be combined with bash tool access", + }, + { + name: "disable-xpia-prompt false with bash tool - allowed", + data: &WorkflowData{ + Features: map[string]any{ + "disable-xpia-prompt": false, + }, + ParsedTools: NewTools(map[string]any{ + "bash": true, + }), + }, + expectError: false, + }, + { + name: "disable-xpia-prompt with bash commands list - rejected", + data: &WorkflowData{ + Features: map[string]any{ + "disable-xpia-prompt": true, + }, + ParsedTools: NewTools(map[string]any{ + "bash": []any{"npm", "node"}, + }), + }, + expectError: true, + errorMsg: "disable-xpia-prompt cannot be combined with bash tool access", + }, + { + name: "disable-xpia-prompt with nil ParsedTools - allowed", + data: &WorkflowData{ + Features: map[string]any{ + "disable-xpia-prompt": true, + }, + ParsedTools: nil, + }, + expectError: false, + }, } for _, tt := range tests { @@ -245,3 +305,76 @@ func TestValidateFeatures(t *testing.T) { }) } } + +func TestValidateDisableXPIAWithBash(t *testing.T) { + tests := []struct { + name string + data *WorkflowData + expectError bool + errorMsg string + }{ + { + name: "nil data - allowed", + data: nil, + expectError: false, + }, + { + name: "nil ParsedTools - allowed", + data: &WorkflowData{ParsedTools: nil}, + expectError: false, + }, + { + name: "no bash tool - allowed", + data: &WorkflowData{ + ParsedTools: NewTools(map[string]any{}), + }, + expectError: false, + }, + { + name: "bash: true - rejected", + data: &WorkflowData{ + ParsedTools: NewTools(map[string]any{ + "bash": true, + }), + }, + expectError: true, + errorMsg: "disable-xpia-prompt cannot be combined with bash tool access", + }, + { + name: "bash with allowed commands - rejected", + data: &WorkflowData{ + ParsedTools: NewTools(map[string]any{ + "bash": []any{"npm", "node"}, + }), + }, + expectError: true, + errorMsg: "disable-xpia-prompt cannot be combined with bash tool access", + }, + { + name: "only github tool - allowed", + data: &WorkflowData{ + ParsedTools: NewTools(map[string]any{ + "github": map[string]any{}, + }), + }, + expectError: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := validateDisableXPIAWithBash(tt.data) + if tt.expectError { + if err == nil { + t.Errorf("validateDisableXPIAWithBash() expected error, got nil") + } else if tt.errorMsg != "" && !strings.Contains(err.Error(), tt.errorMsg) { + t.Errorf("validateDisableXPIAWithBash() error = %q, want error containing %q", err.Error(), tt.errorMsg) + } + } else { + if err != nil { + t.Errorf("validateDisableXPIAWithBash() unexpected error: %v", err) + } + } + }) + } +} From 655c213b20206f66fa7ecb37d7b9aea254401310 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 23 Apr 2026 12:25:15 +0000 Subject: [PATCH 3/4] fix: improve doc comment style in validateDisableXPIAWithBash Agent-Logs-Url: https://github.com/github/gh-aw/sessions/efedb545-e8e5-4f6a-8028-a53bfe718ef5 Co-authored-by: szabta89 <1330202+szabta89@users.noreply.github.com> --- pkg/workflow/features_validation.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pkg/workflow/features_validation.go b/pkg/workflow/features_validation.go index 090b6be7fc5..6377af5370e 100644 --- a/pkg/workflow/features_validation.go +++ b/pkg/workflow/features_validation.go @@ -71,8 +71,8 @@ func validateFeatures(data *WorkflowData) error { // validateDisableXPIAWithBash rejects the dangerous combination of disable-xpia-prompt: true // and bash tool access. When XPIA protection is disabled, the agent has no framing to // distinguish adversarial instructions from legitimate ones. Combined with bash tool access, -// a prompt-injection payload can trivially escalate to arbitrary shell command execution -// (e.g. npm install of a malicious package with lifecycle scripts). +// a prompt-injection payload can trivially escalate to arbitrary shell command execution, +// such as npm install of a malicious package with lifecycle scripts. func validateDisableXPIAWithBash(data *WorkflowData) error { if data == nil || data.ParsedTools == nil { return nil From e4c68ecf4d6dad08afe4a76a5567cc5f88cac83f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 23 Apr 2026 13:16:22 +0000 Subject: [PATCH 4/4] security: move disable-xpia-prompt guard to strict mode only Agent-Logs-Url: https://github.com/github/gh-aw/sessions/d4a467c6-862f-4a0c-86a3-73556f15e25b Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- pkg/workflow/features_validation.go | 40 ------ pkg/workflow/features_validation_test.go | 123 +----------------- .../strict_mode_permissions_validation.go | 31 +++++ pkg/workflow/strict_mode_validation.go | 8 ++ pkg/workflow/strict_mode_validation_test.go | 110 ++++++++++++++++ 5 files changed, 150 insertions(+), 162 deletions(-) diff --git a/pkg/workflow/features_validation.go b/pkg/workflow/features_validation.go index 6377af5370e..b38ceb4a7a7 100644 --- a/pkg/workflow/features_validation.go +++ b/pkg/workflow/features_validation.go @@ -5,14 +5,12 @@ // This file validates feature flag values to ensure they meet requirements // before being used in workflow compilation. It ensures that: // - action-tag uses a full 40-character SHA or a version tag when specified -// - disable-xpia-prompt is not combined with bash tool access (supply-chain attack vector) // - Other feature-specific constraints are met // // # Validation Functions // // - validateFeatures() - Validates all feature flags in WorkflowData // - validateActionTag() - Validates action-tag is a full SHA or version tag -// - validateDisableXPIAWithBash() - Rejects disable-xpia-prompt combined with bash tools // - isValidFullSHA() - Checks if a string is a valid 40-character SHA // - semverutil.IsActionVersionTag() - Checks if a string is a valid version tag (in pkg/semverutil) // @@ -28,7 +26,6 @@ package workflow import ( "fmt" - "github.com/github/gh-aw/pkg/constants" "github.com/github/gh-aw/pkg/gitutil" "github.com/github/gh-aw/pkg/semverutil" ) @@ -54,47 +51,10 @@ func validateFeatures(data *WorkflowData) error { featuresValidationLog.Print("Action-tag validation passed") } - // Validate that disable-xpia-prompt is not combined with bash tool access - if isFeatureEnabled(constants.DisableXPIAPromptFeatureFlag, data) { - featuresValidationLog.Print("Validating disable-xpia-prompt combination") - if err := validateDisableXPIAWithBash(data); err != nil { - featuresValidationLog.Printf("disable-xpia-prompt combination validation failed: %v", err) - return err - } - featuresValidationLog.Print("disable-xpia-prompt combination validation passed") - } - featuresValidationLog.Print("Features validation completed successfully") return nil } -// validateDisableXPIAWithBash rejects the dangerous combination of disable-xpia-prompt: true -// and bash tool access. When XPIA protection is disabled, the agent has no framing to -// distinguish adversarial instructions from legitimate ones. Combined with bash tool access, -// a prompt-injection payload can trivially escalate to arbitrary shell command execution, -// such as npm install of a malicious package with lifecycle scripts. -func validateDisableXPIAWithBash(data *WorkflowData) error { - if data == nil || data.ParsedTools == nil { - return nil - } - - if data.ParsedTools.Bash == nil { - return nil - } - - return NewValidationError( - "features.disable-xpia-prompt", - "true", - "disable-xpia-prompt cannot be combined with bash tool access. "+ - "Disabling XPIA protection removes the primary defense against prompt-injection attacks. "+ - "When combined with bash tool access, a prompt-injection payload can escalate to "+ - "arbitrary shell command execution (e.g. npm install of an attacker-controlled package).", - "Either re-enable XPIA protection by removing the disable-xpia-prompt feature flag, "+ - "or remove the bash tool from the workflow's tool configuration.\n"+ - "If shell access is required, keep XPIA protection enabled (omit disable-xpia-prompt or set it to false).", - ) -} - // validateActionTag validates that action-tag is a full 40-character SHA or a version tag when specified func validateActionTag(value any) error { // Allow empty or nil values diff --git a/pkg/workflow/features_validation_test.go b/pkg/workflow/features_validation_test.go index 6df1c873849..42a77fe6600 100644 --- a/pkg/workflow/features_validation_test.go +++ b/pkg/workflow/features_validation_test.go @@ -227,65 +227,17 @@ func TestValidateFeatures(t *testing.T) { expectError: false, }, { - name: "disable-xpia-prompt with no bash tool - allowed", + name: "disable-xpia-prompt with bash tool - allowed in non-strict mode", data: &WorkflowData{ Features: map[string]any{ "disable-xpia-prompt": true, }, - ParsedTools: NewTools(map[string]any{ - "github": map[string]any{}, - }), - }, - expectError: false, - }, - { - name: "disable-xpia-prompt with bash tool - rejected", - data: &WorkflowData{ - Features: map[string]any{ - "disable-xpia-prompt": true, - }, - ParsedTools: NewTools(map[string]any{ - "bash": true, - }), - }, - expectError: true, - errorMsg: "disable-xpia-prompt cannot be combined with bash tool access", - }, - { - name: "disable-xpia-prompt false with bash tool - allowed", - data: &WorkflowData{ - Features: map[string]any{ - "disable-xpia-prompt": false, - }, ParsedTools: NewTools(map[string]any{ "bash": true, }), }, expectError: false, }, - { - name: "disable-xpia-prompt with bash commands list - rejected", - data: &WorkflowData{ - Features: map[string]any{ - "disable-xpia-prompt": true, - }, - ParsedTools: NewTools(map[string]any{ - "bash": []any{"npm", "node"}, - }), - }, - expectError: true, - errorMsg: "disable-xpia-prompt cannot be combined with bash tool access", - }, - { - name: "disable-xpia-prompt with nil ParsedTools - allowed", - data: &WorkflowData{ - Features: map[string]any{ - "disable-xpia-prompt": true, - }, - ParsedTools: nil, - }, - expectError: false, - }, } for _, tt := range tests { @@ -305,76 +257,3 @@ func TestValidateFeatures(t *testing.T) { }) } } - -func TestValidateDisableXPIAWithBash(t *testing.T) { - tests := []struct { - name string - data *WorkflowData - expectError bool - errorMsg string - }{ - { - name: "nil data - allowed", - data: nil, - expectError: false, - }, - { - name: "nil ParsedTools - allowed", - data: &WorkflowData{ParsedTools: nil}, - expectError: false, - }, - { - name: "no bash tool - allowed", - data: &WorkflowData{ - ParsedTools: NewTools(map[string]any{}), - }, - expectError: false, - }, - { - name: "bash: true - rejected", - data: &WorkflowData{ - ParsedTools: NewTools(map[string]any{ - "bash": true, - }), - }, - expectError: true, - errorMsg: "disable-xpia-prompt cannot be combined with bash tool access", - }, - { - name: "bash with allowed commands - rejected", - data: &WorkflowData{ - ParsedTools: NewTools(map[string]any{ - "bash": []any{"npm", "node"}, - }), - }, - expectError: true, - errorMsg: "disable-xpia-prompt cannot be combined with bash tool access", - }, - { - name: "only github tool - allowed", - data: &WorkflowData{ - ParsedTools: NewTools(map[string]any{ - "github": map[string]any{}, - }), - }, - expectError: false, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - err := validateDisableXPIAWithBash(tt.data) - if tt.expectError { - if err == nil { - t.Errorf("validateDisableXPIAWithBash() expected error, got nil") - } else if tt.errorMsg != "" && !strings.Contains(err.Error(), tt.errorMsg) { - t.Errorf("validateDisableXPIAWithBash() error = %q, want error containing %q", err.Error(), tt.errorMsg) - } - } else { - if err != nil { - t.Errorf("validateDisableXPIAWithBash() unexpected error: %v", err) - } - } - }) - } -} diff --git a/pkg/workflow/strict_mode_permissions_validation.go b/pkg/workflow/strict_mode_permissions_validation.go index edd38ea0fac..aea44f1144a 100644 --- a/pkg/workflow/strict_mode_permissions_validation.go +++ b/pkg/workflow/strict_mode_permissions_validation.go @@ -73,6 +73,37 @@ func (c *Compiler) validateStrictDeprecatedFields(frontmatter map[string]any) er return nil } +// validateStrictDisableXPIA refuses use of the disable-xpia-prompt feature flag in strict mode. +// Disabling XPIA (Cross-Prompt Injection Attack) protection removes the primary defense against +// prompt-injection attacks in production workflows. +func (c *Compiler) validateStrictDisableXPIA(frontmatter map[string]any) error { + featuresValue, exists := frontmatter["features"] + if !exists { + return nil + } + featuresMap, ok := featuresValue.(map[string]any) + if !ok { + return nil + } + flagVal, exists := featuresMap["disable-xpia-prompt"] + if !exists { + return nil + } + // Only reject when the flag is explicitly enabled (true / non-empty string) + enabled := false + switch v := flagVal.(type) { + case bool: + enabled = v + case string: + enabled = v != "" + } + if !enabled { + return nil + } + strictModeValidationLog.Printf("disable-xpia-prompt validation failed: feature flag enabled in strict mode") + return errors.New("strict mode: 'disable-xpia-prompt: true' is not allowed because it removes XPIA (Cross-Prompt Injection Attack) protection from the workflow. This eliminates the primary defense against prompt-injection attacks. Remove the disable-xpia-prompt feature flag or set 'strict: false' to disable strict mode") +} + // validateStrictFirewall requires firewall to be enabled in strict mode for copilot and codex engines // when network domains are provided (non-wildcard). // In strict mode, ALL engines (regardless of LLM gateway support) disallow sandbox.agent: false. diff --git a/pkg/workflow/strict_mode_validation.go b/pkg/workflow/strict_mode_validation.go index 92d389078f7..a19824dfc4d 100644 --- a/pkg/workflow/strict_mode_validation.go +++ b/pkg/workflow/strict_mode_validation.go @@ -30,6 +30,7 @@ var strictModeValidationLog = newValidationLogger("strict_mode") // 3. validateStrictMCPNetwork() - Requires top-level network config for container-based MCP servers // 4. validateStrictTools() - Validates tools configuration (e.g., serena local mode) // 5. validateStrictDeprecatedFields() - Refuses deprecated fields +// 6. validateStrictDisableXPIA() - Refuses disable-xpia-prompt feature flag // // Note: Env secrets validation (validateEnvSecrets) is called separately outside of strict mode // to emit warnings in non-strict mode and errors in strict mode. @@ -83,6 +84,13 @@ func (c *Compiler) validateStrictMode(frontmatter map[string]any, networkPermiss } } + // 6. Refuse disable-xpia-prompt feature flag + if err := c.validateStrictDisableXPIA(frontmatter); err != nil { + if returnErr := collector.Add(err); returnErr != nil { + return returnErr // Fail-fast mode + } + } + strictModeValidationLog.Printf("Strict mode validation completed: error_count=%d", collector.Count()) return collector.FormattedError("strict mode") diff --git a/pkg/workflow/strict_mode_validation_test.go b/pkg/workflow/strict_mode_validation_test.go index ebc28607b28..892792a3a34 100644 --- a/pkg/workflow/strict_mode_validation_test.go +++ b/pkg/workflow/strict_mode_validation_test.go @@ -662,3 +662,113 @@ func TestValidateStrictCacheMemoryScope(t *testing.T) { }) } } + +// TestValidateStrictDisableXPIA tests the validateStrictDisableXPIA function +func TestValidateStrictDisableXPIA(t *testing.T) { + tests := []struct { + name string + frontmatter map[string]any + expectError bool + errorMsg string + }{ + { + name: "no features field - allowed", + frontmatter: map[string]any{"on": "push"}, + expectError: false, + }, + { + name: "features without disable-xpia-prompt - allowed", + frontmatter: map[string]any{ + "on": "push", + "features": map[string]any{ + "action-tag": "v0", + }, + }, + expectError: false, + }, + { + name: "disable-xpia-prompt: false - allowed", + frontmatter: map[string]any{ + "on": "push", + "features": map[string]any{ + "disable-xpia-prompt": false, + }, + }, + expectError: false, + }, + { + name: "disable-xpia-prompt: true - rejected", + frontmatter: map[string]any{ + "on": "push", + "features": map[string]any{ + "disable-xpia-prompt": true, + }, + }, + expectError: true, + errorMsg: "strict mode: 'disable-xpia-prompt: true' is not allowed", + }, + { + name: "disable-xpia-prompt: true with bash tool - rejected (bash state irrelevant)", + frontmatter: map[string]any{ + "on": "push", + "features": map[string]any{ + "disable-xpia-prompt": true, + }, + "tools": map[string]any{ + "bash": true, + }, + }, + expectError: true, + errorMsg: "strict mode: 'disable-xpia-prompt: true' is not allowed", + }, + { + name: "disable-xpia-prompt: true without bash tool - still rejected", + frontmatter: map[string]any{ + "on": "push", + "features": map[string]any{ + "disable-xpia-prompt": true, + }, + }, + expectError: true, + errorMsg: "strict mode: 'disable-xpia-prompt: true' is not allowed", + }, + { + name: "disable-xpia-prompt as non-empty string - rejected", + frontmatter: map[string]any{ + "on": "push", + "features": map[string]any{ + "disable-xpia-prompt": "yes", + }, + }, + expectError: true, + errorMsg: "strict mode: 'disable-xpia-prompt: true' is not allowed", + }, + { + name: "disable-xpia-prompt as empty string - allowed", + frontmatter: map[string]any{ + "on": "push", + "features": map[string]any{ + "disable-xpia-prompt": "", + }, + }, + expectError: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + compiler := NewCompiler() + err := compiler.validateStrictDisableXPIA(tt.frontmatter) + + if tt.expectError && err == nil { + t.Error("Expected validation to fail but it succeeded") + } else if !tt.expectError && err != nil { + t.Errorf("Expected validation to succeed but it failed: %v", err) + } else if tt.expectError && err != nil && tt.errorMsg != "" { + if !strings.Contains(err.Error(), tt.errorMsg) { + t.Errorf("Expected error containing '%s', got '%s'", tt.errorMsg, err.Error()) + } + } + }) + } +}