Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
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
2 changes: 1 addition & 1 deletion .github/workflows/mcp-inspector.lock.yml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 4 additions & 0 deletions pkg/parser/schemas/main_workflow_schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -3232,6 +3232,10 @@
},
"$comment": "Empty array is valid and means deny all network access. Omit the field entirely or use network: defaults to use default network permissions. Wildcard patterns like '*.example.com' are allowed; only standalone '*' is blocked in strict mode."
},
"allowed-input": {
"type": "boolean",
"description": "When true and the workflow uses workflow_call, expose a network_allowed string input on the compiled lock file. The caller-supplied value is unioned with network.allowed at runtime, supporting ecosystem identifiers (for example 'rust') or comma-separated domains."
},
"blocked": {
"type": "array",
"description": "List of blocked domains or ecosystem identifiers (e.g., 'python', 'node', 'tracker.example.com'). Blocked domains take precedence over allowed domains.",
Expand Down
73 changes: 70 additions & 3 deletions pkg/workflow/awf_helpers.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
package workflow

import (
"encoding/json"
"fmt"
"sort"
"strings"
Expand All @@ -35,6 +36,7 @@ var awfHelpersLog = logger.New("workflow:awf_helpers")

const (
awfArcDindPrefixArgsVarName = "GH_AW_DOCKER_HOST_PATH_PREFIX_ARGS"
awfConfigRuntimePathExpr = "${RUNNER_TEMP}/gh-aw/awf-config.json"
// Bash regex used in [[ ... =~ ... ]] to detect TCP Docker hosts (ARC/DinD).
// Any tcp:// DOCKER_HOST indicates the Docker daemon runs on a separate filesystem,
// requiring --docker-host-path-prefix so AWF bind-mounts resolve against the daemon.
Expand Down Expand Up @@ -78,6 +80,62 @@ type AWFCommandConfig struct {
ExcludeEnvVarNames []string
}

func shouldUseWorkflowCallNetworkAllowedInput(data *WorkflowData) bool {
return data != nil &&
data.NetworkPermissions != nil &&
data.NetworkPermissions.AllowedInput &&
hasWorkflowCallTrigger(data.On)
}

func buildWorkflowCallNetworkAllowedUpdateScript() (string, error) {
ecosystemMap := make(map[string][]string, len(ecosystemDomains)+len(compoundEcosystems))
for ecosystem := range ecosystemDomains {
ecosystemMap[ecosystem] = getEcosystemDomains(ecosystem)
}
for ecosystem := range compoundEcosystems {
ecosystemMap[ecosystem] = getEcosystemDomains(ecosystem)
}

ecosystemJSON, err := json.Marshal(ecosystemMap)
if err != nil {
return "", fmt.Errorf("marshal network allowed ecosystem map: %w", err)
}

return fmt.Sprintf(`python - <<'PY'
import json
import os
from pathlib import Path

config_path = Path(%q)
try:
config = json.loads(config_path.read_text())
except FileNotFoundError as exc:
raise SystemExit(f"Missing AWF config file at {config_path}") from exc
except json.JSONDecodeError as exc:
raise SystemExit(f"Invalid AWF config JSON at {config_path}: {exc}") from exc
except OSError as exc:
raise SystemExit(f"Failed to read AWF config file at {config_path}: {exc}") from exc

network_allowed = os.environ.get(%q, "")
tokens = [token.strip() for token in network_allowed.split(",") if token.strip()]

if tokens:
ecosystem_map = json.loads(r'''%s''')
allow_domains = config.setdefault("network", {}).setdefault("allowDomains", [])
seen = set(allow_domains)
for token in tokens:
for domain in ecosystem_map.get(token, [token]):
if domain not in seen:
allow_domains.append(domain)
seen.add(domain)

try:
config_path.write_text(json.dumps(config, separators=(",", ":"), ensure_ascii=False) + "\n")
except OSError as exc:
raise SystemExit(f"Failed to write AWF config file at {config_path}: {exc}") from exc
PY`, awfConfigRuntimePathExpr, string(WorkflowCallNetworkAllowedEnvVar), string(ecosystemJSON)), nil
}

// BuildAWFCommand builds a complete AWF command with all arguments.
// This consolidates the AWF command building logic that was duplicated across
// Copilot, Claude, and Codex engines.
Expand Down Expand Up @@ -147,12 +205,21 @@ fi`,
// startup) and also copy it to /tmp/gh-aw/awf-config.json so the unified agent artifact
// upload can include it alongside the other /tmp/gh-aw/ files.
configFileSetup = fmt.Sprintf(
"printf '%%s\\n' %s > \"${RUNNER_TEMP}/gh-aw/awf-config.json\" && cp \"${RUNNER_TEMP}/gh-aw/awf-config.json\" %s",
"printf '%%s\\n' %s > %q",
shellEscapeArg(awfConfigJSON),
constants.AWFConfigFilePath,
awfConfigRuntimePathExpr,
)
if shouldUseWorkflowCallNetworkAllowedInput(config.WorkflowData) {
updateScript, updateErr := buildWorkflowCallNetworkAllowedUpdateScript()
if updateErr != nil {
awfHelpersLog.Printf("Warning: failed to build workflow_call network_allowed updater: %v", updateErr)
} else {
configFileSetup += "\n" + updateScript
}
}
configFileSetup += fmt.Sprintf("\ncp %q %s", awfConfigRuntimePathExpr, constants.AWFConfigFilePath)
// Add --config as the first expandable arg so it appears before --container-workdir.
expandableArgs = `--config "${RUNNER_TEMP}/gh-aw/awf-config.json" ` + expandableArgs
expandableArgs = fmt.Sprintf("--config %q ", awfConfigRuntimePathExpr) + expandableArgs
awfHelpersLog.Print("Using AWF config file (--config flag)")
}

Expand Down
63 changes: 63 additions & 0 deletions pkg/workflow/call_workflow_compilation_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -288,6 +288,69 @@ Comment on the issue and then call the worker.
assert.Contains(t, yaml, "call_workflow_payload:", "Should declare call_workflow_payload output")
}

func TestWorkflowCallCompile_InjectsNetworkAllowedInputWhenEnabled(t *testing.T) {
tmpDir := testutil.TempDir(t, "workflow-call-network-allowed")
workflowsDir := filepath.Join(tmpDir, ".github", "workflows")
require.NoError(t, os.MkdirAll(workflowsDir, 0755))

workflowMD := `---
on: workflow_call
engine: copilot
permissions:
contents: read
network:
allowed:
- defaults
allowed-input: true
---

# Reusable worker

Compile with an optional workflow_call network input.
`
lockYAML := compileAndReadLock(t, filepath.Join(workflowsDir, "worker.md"), workflowMD)

onLockSection := extractWorkflowCallSection(lockYAML)
workflowCallIdx := strings.Index(onLockSection, "workflow_call:")
require.GreaterOrEqual(t, workflowCallIdx, 0, "on section should contain workflow_call:")
workflowCallBlock := onLockSection[workflowCallIdx:]

assert.Contains(t, workflowCallBlock, "network_allowed:", "workflow_call inputs should include network_allowed when opt-in is enabled")
assert.Contains(t, workflowCallBlock, "type: string", "network_allowed workflow_call input should be typed as string")
assert.Contains(t, lockYAML, "GH_AW_WORKFLOW_CALL_NETWORK_ALLOWED:", "engine step env should expose the workflow_call input at runtime")
assert.Contains(t, lockYAML, "index.crates.io", "runtime network expansion should include known ecosystem domains")
}

func TestWorkflowCallCompile_DoesNotInjectNetworkAllowedInputByDefault(t *testing.T) {
tmpDir := testutil.TempDir(t, "workflow-call-network-default")
workflowsDir := filepath.Join(tmpDir, ".github", "workflows")
require.NoError(t, os.MkdirAll(workflowsDir, 0755))

workflowMD := `---
on: workflow_call
engine: copilot
permissions:
contents: read
network:
allowed:
- defaults
---

# Reusable worker

Compile without the optional workflow_call network input.
`
lockYAML := compileAndReadLock(t, filepath.Join(workflowsDir, "worker.md"), workflowMD)

onLockSection := extractWorkflowCallSection(lockYAML)
workflowCallIdx := strings.Index(onLockSection, "workflow_call:")
require.GreaterOrEqual(t, workflowCallIdx, 0, "on section should contain workflow_call:")
workflowCallBlock := onLockSection[workflowCallIdx:]

assert.NotContains(t, workflowCallBlock, "network_allowed:", "workflow_call inputs should not include network_allowed unless opted in")
assert.NotContains(t, lockYAML, "GH_AW_WORKFLOW_CALL_NETWORK_ALLOWED:", "engine step env should not reference network_allowed unless opted in")
}

// TestCallWorkflowCompile_WorkflowCallGateway tests compilation when the gateway itself
// is triggered via workflow_call (cross-repo use case)
func TestCallWorkflowCompile_WorkflowCallGateway(t *testing.T) {
Expand Down
1 change: 1 addition & 0 deletions pkg/workflow/claude_engine.go
Original file line number Diff line number Diff line change
Expand Up @@ -342,6 +342,7 @@ func (e *ClaudeEngine) GetExecutionSteps(workflowData *WorkflowData, logFile str
"GITHUB_STEP_SUMMARY": AgentStepSummaryPath,
"GITHUB_WORKSPACE": "${{ github.workspace }}",
}
injectWorkflowCallNetworkAllowedEnv(env, workflowData)
// Indicate the phase: "agent" for the main run, "detection" for threat detection
// Include the compiler version so agents can identify which gh-aw version generated the workflow
if workflowData.IsDetectionRun {
Expand Down
1 change: 1 addition & 0 deletions pkg/workflow/codex_engine.go
Original file line number Diff line number Diff line change
Expand Up @@ -338,6 +338,7 @@ mkdir -p "$CODEX_HOME/logs"
"GITHUB_PERSONAL_ACCESS_TOKEN": effectiveGitHubToken, // Used by GitHub MCP server via env_vars
"OPENAI_API_KEY": "${{ secrets.CODEX_API_KEY || secrets.OPENAI_API_KEY }}", // Fallback for CODEX_API_KEY
}
injectWorkflowCallNetworkAllowedEnv(env, workflowData)
// Indicate the phase: "agent" for the main run, "detection" for threat detection
// Include the compiler version so agents can identify which gh-aw version generated the workflow
if workflowData.IsDetectionRun {
Expand Down
54 changes: 39 additions & 15 deletions pkg/workflow/compiler_aw_context.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,16 @@ var awContextLog = logger.New("workflow:compiler_aw_context")
// It is managed internally by the agentic workflow system and should not be surfaced to users.
const AwContextInputName = "aw_context"

// NetworkAllowedInputName is the optional workflow_call input that extends the compiled
// network allowlist at runtime for reusable workflows.
const NetworkAllowedInputName = "network_allowed"

// awContextInputDescription is the description for the aw_context workflow_dispatch input.
// It signals to users that this input is managed internally by the agentic workflow system.
const awContextInputDescription = "Agent caller context (used internally by Agentic Workflows)."

const networkAllowedInputDescription = "Additional allowed network domains or ecosystem identifiers to union with network.allowed (comma-separated, for example: \"rust\" or \"python,github.com\")."

// injectAwContextIntoOnYAML adds the aw_context input to internal workflow triggers
// in the given on-section YAML string.
//
Expand All @@ -30,17 +36,24 @@ const awContextInputDescription = "Agent caller context (used internally by Agen
//
// The function is idempotent: calling it twice produces the same result.
func injectAwContextIntoOnYAML(onSection string) string {
updated := injectAwContextIntoTrigger(onSection, "workflow_dispatch")
updated = injectAwContextIntoTrigger(updated, "workflow_call")
updated := injectInputIntoTrigger(onSection, "workflow_dispatch", AwContextInputName, buildAwContextInputLines)
updated = injectInputIntoTrigger(updated, "workflow_call", AwContextInputName, buildAwContextInputLines)
return updated
}

func injectAwContextIntoTrigger(onSection string, triggerName string) string {
func injectNetworkAllowedIntoOnYAML(onSection string, network *NetworkPermissions) string {
if network == nil || !network.AllowedInput {
return onSection
}
return injectInputIntoTrigger(onSection, "workflow_call", NetworkAllowedInputName, buildNetworkAllowedInputLines)
}

func injectInputIntoTrigger(onSection string, triggerName string, inputName string, buildInputLines func(int) []string) string {
if !strings.Contains(onSection, triggerName) {
awContextLog.Printf("No %s trigger found, skipping aw_context injection", triggerName)
awContextLog.Printf("No %s trigger found, skipping %s injection", triggerName, inputName)
return onSection
}
awContextLog.Printf("Injecting aw_context input into %s trigger", triggerName)
awContextLog.Printf("Injecting %s input into %s trigger", inputName, triggerName)

lines := strings.Split(onSection, "\n")

Expand All @@ -61,10 +74,10 @@ func injectAwContextIntoTrigger(onSection string, triggerName string) string {
}

if triggerLineIdx == -1 {
awContextLog.Printf("No bare %s: line found, skipping aw_context injection", triggerName)
awContextLog.Printf("No bare %s: line found, skipping %s injection", triggerName, inputName)
return onSection
}
awContextLog.Printf("Found %s at line %d (indent=%d), injecting aw_context", triggerName, triggerLineIdx, triggerIndent)
awContextLog.Printf("Found %s at line %d (indent=%d), injecting %s", triggerName, triggerLineIdx, triggerIndent, inputName)

// Look for an "inputs:" key directly inside the trigger block.
// Only the first non-empty, non-comment line after the trigger matters.
Expand Down Expand Up @@ -95,16 +108,16 @@ func injectAwContextIntoTrigger(onSection string, triggerName string) string {
if lineIndent <= inputsIndent {
break
}
if strings.HasPrefix(stripped, AwContextInputName+":") {
awContextLog.Printf("aw_context already injected into %s, skipping", triggerName)
if strings.HasPrefix(stripped, inputName+":") {
awContextLog.Printf("%s already injected into %s, skipping", inputName, triggerName)
return onSection
}
}
}

awContextLines := buildAwContextInputLines(triggerIndent)
inputLines := buildInputLines(triggerIndent)

result := make([]string, 0, len(lines)+len(awContextLines)+1)
result := make([]string, 0, len(lines)+len(inputLines)+1)
for i, line := range lines {
// When the trigger line contains an explicit null/~ value,
// replace it with a bare trigger so sub-keys can follow.
Expand All @@ -116,12 +129,11 @@ func injectAwContextIntoTrigger(onSection string, triggerName string) string {
result = append(result, line)

if inputsLineIdx != -1 && i == inputsLineIdx {
// Insert aw_context as the first entry under existing inputs:
result = append(result, awContextLines...)
result = append(result, inputLines...)
} else if inputsLineIdx == -1 && i == triggerLineIdx {
// Trigger is bare — add inputs: + aw_context
// Trigger is bare — add inputs: + the requested internal input.
result = append(result, strings.Repeat(" ", triggerIndent+2)+"inputs:")
result = append(result, awContextLines...)
result = append(result, inputLines...)
}
}

Expand All @@ -141,3 +153,15 @@ func buildAwContextInputLines(wdIndent int) []string {
propIndent + "type: string",
}
}

func buildNetworkAllowedInputLines(wdIndent int) []string {
inputIndent := strings.Repeat(" ", wdIndent+4)
propIndent := strings.Repeat(" ", wdIndent+6)
return []string{
inputIndent + NetworkAllowedInputName + ":",
propIndent + "default: \"\"",
propIndent + "description: " + networkAllowedInputDescription,
propIndent + "required: false",
propIndent + "type: string",
}
}
1 change: 1 addition & 0 deletions pkg/workflow/compiler_yaml.go
Original file line number Diff line number Diff line change
Expand Up @@ -268,6 +268,7 @@ func (c *Compiler) generateWorkflowBody(yaml *strings.Builder, data *WorkflowDat
// can receive caller metadata (repo, run_id, actor, etc.) from dispatch_workflow.
// String-based injection preserves existing YAML comments and formatting.
onSection = injectAwContextIntoOnYAML(onSection)
onSection = injectNetworkAllowedIntoOnYAML(onSection, data.NetworkPermissions)
onSection = UnquoteYAMLTopLevelKey(onSection, "on")
yaml.WriteString(onSection)
yaml.WriteString("\n\n")
Expand Down
1 change: 1 addition & 0 deletions pkg/workflow/copilot_engine_execution.go
Original file line number Diff line number Diff line change
Expand Up @@ -343,6 +343,7 @@ touch %s
"GITHUB_SERVER_URL": "${{ github.server_url }}",
"GITHUB_API_URL": "${{ github.api_url }}",
}
injectWorkflowCallNetworkAllowedEnv(env, workflowData)

// When copilot-requests feature is enabled, set S2STOKENS=true to allow the Copilot CLI
// to accept GitHub App installation tokens (ghs_*) such as ${{ github.token }}.
Expand Down
1 change: 1 addition & 0 deletions pkg/workflow/crush_engine.go
Original file line number Diff line number Diff line change
Expand Up @@ -205,6 +205,7 @@ func (e *CrushEngine) GetExecutionSteps(workflowData *WorkflowData, logFile stri
"GITHUB_WORKSPACE": "${{ github.workspace }}",
"NO_PROXY": "localhost,127.0.0.1",
}
injectWorkflowCallNetworkAllowedEnv(env, workflowData)
e.ApplyUniversalProviderEnv(env, workflowData, firewallEnabled)

// MCP config path
Expand Down
11 changes: 10 additions & 1 deletion pkg/workflow/engine.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,14 @@ import (

var engineLog = logger.New("workflow:engine")

const WorkflowCallNetworkAllowedEnvVar = "GH_AW_WORKFLOW_CALL_NETWORK_ALLOWED"

func injectWorkflowCallNetworkAllowedEnv(env map[string]string, workflowData *WorkflowData) {
if shouldUseWorkflowCallNetworkAllowedInput(workflowData) {
env[WorkflowCallNetworkAllowedEnvVar] = fmt.Sprintf("${{ inputs.%s }}", NetworkAllowedInputName)
}
}

// EngineConfig represents the parsed engine configuration
type EngineConfig struct {
ID string
Expand Down Expand Up @@ -105,7 +113,8 @@ type EngineAuthConfig struct {
// Ecosystem identifiers in the Allowed list are expanded to their corresponding domain lists.
// See GetAllowedDomains() for the list of supported ecosystem identifiers.
type NetworkPermissions struct {
Allowed []string `yaml:"allowed,omitempty"` // List of allowed domains or ecosystem identifiers (e.g., "defaults", "github", "python")
Allowed []string `yaml:"allowed,omitempty"` // List of allowed domains or ecosystem identifiers (e.g., "defaults", "github", "python")
AllowedInput bool `yaml:"allowed-input,omitempty"`
Blocked []string `yaml:"blocked,omitempty"` // List of blocked domains (takes precedence over allowed)
Firewall *FirewallConfig `yaml:"firewall,omitempty"` // AWF firewall configuration (see firewall.go)
ExplicitlyDefined bool `yaml:"-"` // Internal flag: true if network field was explicitly set in frontmatter
Expand Down
6 changes: 6 additions & 0 deletions pkg/workflow/frontmatter_extraction_security.go
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,12 @@ func (c *Compiler) extractNetworkPermissions(frontmatter map[string]any) *Networ
}
}

if allowedInput, hasAllowedInput := networkObj["allowed-input"]; hasAllowedInput {
if allowedInputBool, ok := allowedInput.(bool); ok {
permissions.AllowedInput = allowedInputBool
}
}

// Extract blocked domains if present
if blocked, hasBlocked := networkObj["blocked"]; hasBlocked {
if blockedSlice, ok := blocked.([]any); ok {
Expand Down
3 changes: 3 additions & 0 deletions pkg/workflow/frontmatter_serialization.go
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,9 @@ func (fc *FrontmatterConfig) ToMap() map[string]any {
if len(fc.Network.Allowed) > 0 {
networkMap["allowed"] = fc.Network.Allowed
}
if fc.Network.AllowedInput {
networkMap["allowed-input"] = true
}
Comment on lines +150 to +152
if len(fc.Network.Blocked) > 0 {
networkMap["blocked"] = fc.Network.Blocked
}
Expand Down
Loading
Loading