diff --git a/docs/public/schemas/mcp-gateway-config.schema.json b/docs/public/schemas/mcp-gateway-config.schema.json index 806eebe0d40..9ef8960b3e2 100644 --- a/docs/public/schemas/mcp-gateway-config.schema.json +++ b/docs/public/schemas/mcp-gateway-config.schema.json @@ -163,6 +163,18 @@ }, "default": ["*"] }, + "connectTimeout": { + "type": "integer", + "description": "Per-transport timeout in seconds while connecting to an HTTP MCP upstream.", + "minimum": 1, + "default": 30 + }, + "toolTimeout": { + "type": "integer", + "description": "Per-server timeout in seconds for a tool invocation.", + "minimum": 1, + "default": 60 + }, "env": { "type": "object", "description": "Environment variables to pass through for variable resolution. Values may contain variable expressions using '${VARIABLE_NAME}' syntax, which will be resolved from the process environment.", diff --git a/docs/src/content/docs/reference/enclaves.md b/docs/src/content/docs/reference/enclaves.md new file mode 100644 index 00000000000..084e540e3da --- /dev/null +++ b/docs/src/content/docs/reference/enclaves.md @@ -0,0 +1,33 @@ +--- +title: Private repository enclaves +description: Configure unified AWF script and agent enclaves through the trusted MCP gateway. +--- + +The top-level `enclaves` array enables finite-disclosure access to approved private repositories. The compiler registers `enclave_run_script` or `enclave_run_agent` from the keyed entries present on the `awf-enclave` MCP route. Omit the array to disable enclaves. + +Enclaves require AWF network isolation. Configure `sandbox.agent.sudo: false` (or the `docker-sbx` runtime) so the compiler launches mcpg in bridge mode and AWF can attach it to the isolated topology. + +```yaml +sandbox: + agent: + id: awf + sudo: false +enclaves: + - script: + repos: + - repo: octo-org/private-service + sensitivity: confidential + timeout: 45 + - agent: + model: gpt-5 + repos: + - repo: octo-org/private-service + sensitivity: confidential + timeout: 180 +``` + +Each type can appear at most once. When the same repository appears in both entries, its sensitivity must match because its information budget is shared across executor types. AWF fixes the script enclave network and interpreter and the agent enclave network internally; workflows cannot override those security invariants. + +The generated gateway upstream uses a fresh masked capability for each workflow run. That capability is passed only to mcpg and AWF and is excluded from the primary agent environment. The gateway allows 120 seconds for the AWF-owned HTTP upstream to become available. It enforces a 630-second tool timeout, covering AWF's maximum 600-second finite-disclosure timing bucket plus a 30-second transport allowance. Executor timeouts are capped at 540 seconds because AWF reserves 60 seconds in the final bucket for processing and cleanup. The gateway timeout is an enforcement bound, not an absolute AWF wall-clock guarantee under pathological host cleanup or scheduler stalls. + +This compiler contract depends on the unified enclave implementation from `github/gh-aw-firewall#6992`. Until that change is available in an AWF release, pinning an older AWF version will not provide the enclave server. diff --git a/pkg/parser/schema_test.go b/pkg/parser/schema_test.go index 28933618a1e..f403813d65b 100644 --- a/pkg/parser/schema_test.go +++ b/pkg/parser/schema_test.go @@ -9,6 +9,65 @@ import ( "testing" ) +func TestValidateMainWorkflowFrontmatterEnclaves(t *testing.T) { + valid := map[string]any{ + "on": "workflow_dispatch", + "engine": "copilot", + "enclaves": []any{ + map[string]any{ + "script": nil, + "repos": []any{ + map[string]any{"repo": "octo-org/private-service", "sensitivity": "confidential"}, + }, + "timeout": 45, + }, + map[string]any{ + "agent": map[string]any{"model": "gpt-5"}, + "repos": []any{ + map[string]any{"repo": "octo-org/private-service", "sensitivity": "confidential"}, + }, + "timeout": 540, + }, + }, + } + if err := ValidateMainWorkflowFrontmatterWithSchemaAndLocation(valid, "workflow.md"); err != nil { + t.Fatalf("expected keyed top-level enclaves to validate: %v", err) + } + + legacy := map[string]any{ + "on": "workflow_dispatch", + "engine": "copilot", + "sandbox": map[string]any{ + "enclaves": []any{ + map[string]any{ + "type": "script", + "repositories": []any{}, + }, + }, + }, + } + if err := ValidateMainWorkflowFrontmatterWithSchemaAndLocation(legacy, "workflow.md"); err == nil { + t.Fatal("expected legacy sandbox.enclaves shape to be rejected") + } + + tooLong := map[string]any{ + "on": "workflow_dispatch", + "engine": "copilot", + "enclaves": []any{ + map[string]any{ + "agent": map[string]any{"model": "gpt-5"}, + "repos": []any{ + map[string]any{"repo": "octo-org/private-service", "sensitivity": "confidential"}, + }, + "timeout": 541, + }, + }, + } + if err := ValidateMainWorkflowFrontmatterWithSchemaAndLocation(tooLong, "workflow.md"); err == nil { + t.Fatal("expected enclave timeout above 540 seconds to be rejected") + } +} + func TestValidateWithSchema(t *testing.T) { tests := []struct { name string diff --git a/pkg/parser/schemas/main_workflow_schema.json b/pkg/parser/schemas/main_workflow_schema.json index 4bfa6b05ed0..fc38af933c2 100644 --- a/pkg/parser/schemas/main_workflow_schema.json +++ b/pkg/parser/schemas/main_workflow_schema.json @@ -3415,6 +3415,70 @@ } ] }, + "enclaves": { + "type": "array", + "description": "AWF-owned private-repository executors exposed only through the compiler-launched MCP gateway. Omit this field to disable enclaves.", + "minItems": 1, + "maxItems": 2, + "items": { + "oneOf": [ + { + "type": "object", + "additionalProperties": false, + "required": ["script", "repos"], + "properties": { + "script": { + "type": ["object", "null"], + "additionalProperties": false, + "properties": { + "max-script-bytes": { "type": "integer", "minimum": 1, "maximum": 65536, "default": 65536 } + } + }, + "repos": { "$ref": "#/$defs/enclave-repos" }, + "runtime": { "type": "string", "enum": ["docker", "gvisor", "sbx"], "default": "docker" }, + "image": { "type": "string", "minLength": 1, "maxLength": 500 }, + "timeout": { "type": "integer", "minimum": 1, "maximum": 540, "default": 30 }, + "memory-limit": { "type": "string", "pattern": "^[1-9][0-9]*[bkmgBKMG]$", "default": "512m" }, + "cpu-limit": { "type": "string", "pattern": "^(?:[0-9]{1,2})(?:\\.[0-9]{1,3})?$", "default": "1" }, + "pids-limit": { "type": "integer", "minimum": 1, "maximum": 4096, "default": 128 }, + "tmpfs-limit": { "type": "string", "pattern": "^[1-9][0-9]*[bkmgBKMG]$", "default": "64m" }, + "max-output-bytes": { "type": "integer", "minimum": 1, "maximum": 8192, "default": 8192 }, + "max-invocations": { "type": "integer", "minimum": 1, "maximum": 10000, "default": 32 } + } + }, + { + "type": "object", + "additionalProperties": false, + "required": ["agent", "repos"], + "properties": { + "agent": { + "type": "object", + "additionalProperties": false, + "required": ["model"], + "properties": { + "engine": { "type": "string", "enum": ["copilot", "claude", "codex", "gemini"], "default": "copilot" }, + "profile": { "type": "string", "enum": ["openai", "anthropic"], "default": "openai" }, + "model": { "type": "string", "minLength": 1, "maxLength": 200, "pattern": "^[A-Za-z0-9][A-Za-z0-9._:@/-]{0,199}$" }, + "max-task-bytes": { "type": "integer", "minimum": 1, "maximum": 65536, "default": 4096 }, + "max-model-requests": { "type": "integer", "minimum": 1, "maximum": 64, "default": 8 }, + "max-model-tokens": { "type": "integer", "minimum": 1, "maximum": 32768, "default": 1024 } + } + }, + "repos": { "$ref": "#/$defs/enclave-repos" }, + "runtime": { "type": "string", "enum": ["docker", "gvisor", "sbx"], "default": "docker" }, + "image": { "type": "string", "minLength": 1, "maxLength": 500 }, + "timeout": { "type": "integer", "minimum": 1, "maximum": 540, "default": 120 }, + "memory-limit": { "type": "string", "pattern": "^[1-9][0-9]*[bkmgBKMG]$", "default": "512m" }, + "cpu-limit": { "type": "string", "pattern": "^(?:[0-9]{1,2})(?:\\.[0-9]{1,3})?$", "default": "1" }, + "pids-limit": { "type": "integer", "minimum": 1, "maximum": 4096, "default": 128 }, + "tmpfs-limit": { "type": "string", "pattern": "^[1-9][0-9]*[bkmgBKMG]$", "default": "64m" }, + "max-output-bytes": { "type": "integer", "minimum": 1, "maximum": 8192, "default": 8192 }, + "max-invocations": { "type": "integer", "minimum": 1, "maximum": 1000, "default": 8 } + } + } + ] + } + }, "sandbox": { "description": "Sandbox configuration for AI engines. Controls agent sandbox (AWF) and MCP gateway. The MCP gateway is always enabled and cannot be disabled.", "oneOf": [ @@ -3425,7 +3489,7 @@ }, { "type": "object", - "description": "Object format for full sandbox configuration with agent and mcp options", + "description": "Object format for full sandbox configuration with agent and MCP gateway options", "properties": { "type": { "type": "string", @@ -12339,6 +12403,26 @@ } ], "$defs": { + "enclave-repos": { + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "additionalProperties": false, + "required": ["repo", "sensitivity"], + "properties": { + "repo": { + "type": "string", + "maxLength": 140, + "pattern": "^[A-Za-z0-9][A-Za-z0-9-]{0,38}/[A-Za-z0-9._-]{1,100}$" + }, + "sensitivity": { + "type": "string", + "enum": ["public", "internal", "confidential", "sealed"] + } + } + } + }, "github_actions_runs_on": { "description": "Runner type for workflow execution (GitHub Actions standard field). Supports multiple forms: simple string for single runner label (e.g., 'ubuntu-latest'), array for runner selection with fallbacks, or object for GitHub-hosted runner groups with specific labels. For agentic workflows, runner selection matters when AI workloads require specific compute resources or when using self-hosted runners with specialized capabilities. Typically configured at the job level instead. See https://docs.github.com/en/actions/using-jobs/choosing-the-runner-for-a-job", "oneOf": [ diff --git a/pkg/workflow/awf_config.go b/pkg/workflow/awf_config.go index 0fe7bf04110..3639d22b694 100644 --- a/pkg/workflow/awf_config.go +++ b/pkg/workflow/awf_config.go @@ -170,6 +170,9 @@ type AWFConfigFile struct { // cross-repository private data access. Omitted when not configured. BoundedQueries *AWFBoundedQueriesConfig `json:"boundedQueries,omitempty"` + // Enclaves configures the unified AWF-owned script and agent enclave subsystem. + Enclaves []map[string]any `json:"enclaves,omitempty"` + // Container contains container execution configuration. Container *AWFContainerConfig `json:"container,omitempty"` @@ -471,6 +474,9 @@ func BuildAWFConfigJSON(config AWFCommandConfig) (string, error) { awfConfig := AWFConfigFile{ Schema: buildAWFConfigSchemaURL(firewallConfig), } + if config.WorkflowData != nil { + awfConfig.Enclaves = buildAWFEnclavesConfig(config.WorkflowData.Enclaves) + } // ── Runner section ────────────────────────────────────────────────────── if topology := getRunnerTopology(config.WorkflowData); topology != "" { diff --git a/pkg/workflow/awf_env.go b/pkg/workflow/awf_env.go index b5c24c4dfd9..262c9a0b2ff 100644 --- a/pkg/workflow/awf_env.go +++ b/pkg/workflow/awf_env.go @@ -156,6 +156,13 @@ func ComputeAWFExcludeEnvVarNames(workflowData *WorkflowData, coreSecretVarNames // The runner-owned gateway forwards them only for HTTP MCP github-oidc authentication. addUnique("ACTIONS_ID_TOKEN_REQUEST_URL") addUnique("ACTIONS_ID_TOKEN_REQUEST_TOKEN") + if enclavesEnabled(workflowData) { + addUnique(enclaveMCPCapabilityEnv) + addUnique(enclaveMCPGatewayContainerEnv) + addUnique(enclaveMCPGatewayEndpointEnv) + addUnique(enclaveMCPGatewayIdentityEnv) + addUnique(enclaveMCPReadinessTimeoutEnv) + } // Explicitly excluded env vars from the frontmatter excluded-env field. // These are always excluded regardless of their value content. diff --git a/pkg/workflow/codex_mcp.go b/pkg/workflow/codex_mcp.go index af12a4d605d..29ff772e8c3 100644 --- a/pkg/workflow/codex_mcp.go +++ b/pkg/workflow/codex_mcp.go @@ -76,6 +76,8 @@ func (e *CodexEngine) RenderMCPConfig(yaml *strings.Builder, tools map[string]an if hasMCPScripts { renderer.RenderMCPScriptsMCP(&mcpConfigContent, workflowData.MCPScripts, workflowData) } + case enclaveMCPServerName: + writeEnclaveMCPTOML(&mcpConfigContent, workflowData) default: // Handle custom MCP tools using shared helper (with adapter for isLast parameter) HandleCustomMCPToolInSwitch(&mcpConfigContent, toolName, expandedTools, false, func(yaml *strings.Builder, toolName string, toolConfig map[string]any, isLast bool) error { diff --git a/pkg/workflow/compiler_validators.go b/pkg/workflow/compiler_validators.go index 2a932c64806..6247fa23f9f 100644 --- a/pkg/workflow/compiler_validators.go +++ b/pkg/workflow/compiler_validators.go @@ -198,6 +198,7 @@ func (c *Compiler) validateCoreToolConfiguration(workflowData *WorkflowData, mar {logMessage: "Validating OTLP workload identity configuration", validateFn: func() error { return validateOTLPWorkloadIdentity(workflowData) }}, {logMessage: "Validating default AI credits pricing values", validateFn: func() error { return validateDefaultAiCreditsPricing(workflowData) }}, {logMessage: "Validating tools.github.bounded-queries configuration", validateFn: func() error { return validateBoundedQueriesConfig(workflowData) }}, + {logMessage: "Validating enclaves configuration", validateFn: func() error { return validateEnclavesConfig(workflowData) }}, } // This validation is intentionally outside the table below because strict mode // turns the same validation result into either an error or a warning. @@ -494,13 +495,13 @@ func validateOTLPWorkloadIdentity(workflowData *WorkflowData) error { return nil } if !strings.EqualFold(strings.TrimSpace(workloadIdentity.Provider), "google") { - return errors.New("observability.otlp.workload-identity.provider must be google") + return errors.New("observability.otlp.workload-identity.provider must be google. Example:\n\nobservability:\n otlp:\n workload-identity:\n provider: google\n audience: my-audience") } if strings.TrimSpace(workloadIdentity.Audience) == "" { - return errors.New("observability.otlp.workload-identity.audience is required") + return errors.New("observability.otlp.workload-identity.audience is required. Example:\n\nobservability:\n otlp:\n workload-identity:\n provider: google\n audience: my-audience") } if getOTLPGitHubAppTokenConfig(workflowData.RawFrontmatter) != nil { - return errors.New("observability.otlp.workload-identity cannot be combined with GitHub App credentials") + return errors.New("observability.otlp.workload-identity cannot be combined with GitHub App credentials; use one authentication method only. Example:\n\nobservability:\n otlp:\n workload-identity:\n provider: google\n audience: my-audience") } return nil } diff --git a/pkg/workflow/enclaves.go b/pkg/workflow/enclaves.go new file mode 100644 index 00000000000..c96550b9635 --- /dev/null +++ b/pkg/workflow/enclaves.go @@ -0,0 +1,275 @@ +package workflow + +import ( + "encoding/json" + "errors" + "fmt" + "regexp" + "strings" +) + +const ( + enclaveMCPServerName = "awf-enclave" + enclaveMCPUpstreamURL = "http://awf-enclave-mcp:8080/mcp" + enclaveMCPCapabilityEnv = "AWF_ENCLAVE_MCP_CAPABILITY" + enclaveMCPGatewayContainerEnv = "AWF_ENCLAVE_MCP_GATEWAY_CONTAINER" + enclaveMCPGatewayEndpointEnv = "AWF_ENCLAVE_MCP_GATEWAY_ENDPOINT" + enclaveMCPGatewayIdentityEnv = "AWF_ENCLAVE_MCP_GATEWAY_IDENTITY" + enclaveMCPReadinessTimeoutEnv = "AWF_ENCLAVE_MCP_READINESS_TIMEOUT_MS" + enclaveMCPGatewayRunLabel = "com.github.gh-aw.mcpg.run" + enclaveMCPGatewayContainer = "awmg-mcpg" + enclaveMCPConnectTimeout = 120 + enclaveMCPReadinessTimeoutMS = 120000 + maxEnclaveTimingBucketSeconds = 600 + enclaveMCPTransportAllowance = 30 +) + +var enclaveRepoPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9-]{0,38}/[A-Za-z0-9._-]{1,100}$`) + +// EnclavesConfig configures AWF-owned, finite-disclosure private repository executors. +// Each executor type may appear at most once. +type EnclavesConfig []*EnclaveConfig + +type EnclaveRepository struct { + Repo string `json:"repo"` + Sensitivity string `json:"sensitivity"` +} + +type EnclaveConfig struct { + Script *ScriptEnclaveConfig `json:"script,omitempty"` + Agent *AgentEnclaveConfig `json:"agent,omitempty"` + Repos []*EnclaveRepository `json:"repos"` + Runtime string `json:"runtime,omitempty"` + Image string `json:"image,omitempty"` + Timeout int `json:"timeout,omitempty"` + MemoryLimit string `json:"memory-limit,omitempty"` + CPULimit string `json:"cpu-limit,omitempty"` + PIDsLimit int `json:"pids-limit,omitempty"` + TmpfsLimit string `json:"tmpfs-limit,omitempty"` + MaxOutputBytes int `json:"max-output-bytes,omitempty"` + MaxInvocations int `json:"max-invocations,omitempty"` +} + +type ScriptEnclaveConfig struct { + MaxScriptBytes int `json:"max-script-bytes,omitempty"` +} + +type AgentEnclaveConfig struct { + Engine string `json:"engine,omitempty"` + Profile string `json:"profile,omitempty"` + Model string `json:"model,omitempty"` + MaxTaskBytes int `json:"max-task-bytes,omitempty"` + MaxModelRequests int `json:"max-model-requests,omitempty"` + MaxModelTokens int `json:"max-model-tokens,omitempty"` +} + +// UnmarshalJSON preserves the explicit null marker produced by YAML `script:`. +func (e *EnclaveConfig) UnmarshalJSON(data []byte) error { + type enclaveAlias EnclaveConfig + var raw map[string]json.RawMessage + if err := json.Unmarshal(data, &raw); err != nil { + return err + } + var decoded enclaveAlias + if err := json.Unmarshal(data, &decoded); err != nil { + return err + } + *e = EnclaveConfig(decoded) + if script, ok := raw["script"]; ok && string(script) == "null" { + e.Script = &ScriptEnclaveConfig{} + } + return nil +} + +func enclavesEnabled(workflowData *WorkflowData) bool { + return workflowData != nil && len(workflowData.Enclaves) > 0 +} + +func enabledEnclaveTools(workflowData *WorkflowData) []string { + var tools []string + for _, enclave := range workflowData.Enclaves { + if enclave == nil { + continue + } + if enclave.Script != nil { + tools = append(tools, "enclave_run_script") + } + if enclave.Agent != nil { + tools = append(tools, "enclave_run_agent") + } + } + return tools +} + +func enclaveToolTimeout(workflowData *WorkflowData) int { + if !enclavesEnabled(workflowData) { + return 0 + } + return maxEnclaveTimingBucketSeconds + enclaveMCPTransportAllowance +} + +func validateEnclavesConfig(workflowData *WorkflowData) error { + if !enclavesEnabled(workflowData) { + return nil + } + if !isAWFNetworkIsolationEnabled(workflowData) { + return errors.New("enclaves requires AWF network isolation; set sandbox.agent.sudo: false or use sandbox.agent.runtime: docker-sbx") + } + if workflowData.ParsedTools != nil && + workflowData.ParsedTools.GitHub != nil && + workflowData.ParsedTools.GitHub.BoundedQueries != nil { + return errors.New("enclaves cannot be combined with tools.github.bounded-queries; remove tools.github.bounded-queries to use enclaves. Example:\n\nenclaves:\n - script:\n repos:\n - repo: org/my-repo\n sensitivity: confidential") + } + seenTypes := make(map[string]struct{}, len(workflowData.Enclaves)) + repositorySensitivities := make(map[string]string) + for i, enclave := range workflowData.Enclaves { + if enclave == nil { + return fmt.Errorf("enclaves[%d] must be an object. Example:\n\nenclaves:\n - script:\n repos:\n - repo: org/my-repo\n sensitivity: confidential", i) + } + enclaveType, ok := enclaveExecutor(enclave) + if !ok { + return fmt.Errorf("enclaves[%d] must contain exactly one of script or agent. Example:\n\nenclaves:\n - script:\n repos:\n - repo: org/my-repo\n sensitivity: confidential", i) + } + if _, ok := seenTypes[enclaveType]; ok { + return fmt.Errorf("enclaves contains duplicate executor type %q; each type may appear at most once. Example:\n\nenclaves:\n - script:\n repos:\n - repo: org/my-repo\n sensitivity: confidential\n - agent:\n model: gpt-5\n repos:\n - repo: org/my-repo\n sensitivity: confidential", enclaveType) + } + seenTypes[enclaveType] = struct{}{} + if enclaveType == "agent" && enclave.Agent.Model == "" { + return fmt.Errorf("enclaves[%d].agent.model is required. Example:\n\nenclaves:\n - agent:\n model: gpt-5\n repos:\n - repo: org/my-repo\n sensitivity: confidential", i) + } + if len(enclave.Repos) == 0 { + return fmt.Errorf("enclaves[%d].repos must contain at least one repository. Example:\n\nenclaves:\n - script:\n repos:\n - repo: org/my-repo\n sensitivity: confidential", i) + } + seenInEnclave := make(map[string]struct{}, len(enclave.Repos)) + for j, repo := range enclave.Repos { + if repo == nil { + return fmt.Errorf("enclaves[%d].repos[%d] must be an object. Example:\n\nenclaves:\n - script:\n repos:\n - repo: org/my-repo\n sensitivity: confidential", i, j) + } + parts := strings.SplitN(repo.Repo, "/", 2) + if !enclaveRepoPattern.MatchString(repo.Repo) || len(parts) != 2 || parts[1] == "." || parts[1] == ".." || strings.Contains(parts[1], "..") { + return fmt.Errorf("enclaves[%d].repos[%d].repo must be a bare owner/repository slug (e.g. org/my-repo). Example:\n\nenclaves:\n - script:\n repos:\n - repo: org/my-repo\n sensitivity: confidential", i, j) + } + key := strings.ToLower(repo.Repo) + if _, ok := seenInEnclave[key]; ok { + return fmt.Errorf("enclaves[%d].repos contains duplicate repository %q; each repository may appear at most once per enclave entry. Example:\n\nenclaves:\n - script:\n repos:\n - repo: org/my-repo\n sensitivity: confidential", i, repo.Repo) + } + seenInEnclave[key] = struct{}{} + switch repo.Sensitivity { + case "public", "internal", "confidential", "sealed": + default: + return fmt.Errorf("enclaves[%d].repos[%d].sensitivity must be public, internal, confidential, or sealed. Example:\n\nenclaves:\n - script:\n repos:\n - repo: org/my-repo\n sensitivity: confidential", i, j) + } + if sensitivity, ok := repositorySensitivities[key]; ok && sensitivity != repo.Sensitivity { + return fmt.Errorf("repository %q must use the same sensitivity across enclave types; all enclave entries for a given repository must declare the same sensitivity. Example:\n\nenclaves:\n - script:\n repos:\n - repo: org/my-repo\n sensitivity: confidential\n - agent:\n model: gpt-5\n repos:\n - repo: org/my-repo\n sensitivity: confidential", repo.Repo) + } + repositorySensitivities[key] = repo.Sensitivity + } + } + return nil +} + +func enclaveExecutor(enclave *EnclaveConfig) (string, bool) { + if enclave.Script != nil && enclave.Agent == nil { + return "script", true + } + if enclave.Agent != nil && enclave.Script == nil { + return "agent", true + } + return "", false +} + +func buildAWFEnclavesConfig(config EnclavesConfig) []map[string]any { + if len(config) == 0 { + return nil + } + result := make([]map[string]any, 0, len(config)) + for _, enclave := range config { + enclaveType, ok := enclaveExecutor(enclave) + if !ok { + continue + } + values := make(map[string]any) + repos := make([]map[string]any, 0, len(enclave.Repos)) + for _, repo := range enclave.Repos { + repos = append(repos, map[string]any{"repo": repo.Repo, "sensitivity": repo.Sensitivity}) + } + values["repos"] = repos + addEnclaveString(values, "runtime", enclave.Runtime) + addEnclaveString(values, "image", enclave.Image) + addEnclaveInt(values, "timeout", enclave.Timeout) + addEnclaveString(values, "memoryLimit", enclave.MemoryLimit) + addEnclaveString(values, "cpuLimit", enclave.CPULimit) + addEnclaveInt(values, "pidsLimit", enclave.PIDsLimit) + addEnclaveString(values, "tmpfsLimit", enclave.TmpfsLimit) + addEnclaveInt(values, "maxOutputBytes", enclave.MaxOutputBytes) + addEnclaveInt(values, "maxInvocations", enclave.MaxInvocations) + if enclaveType == "script" { + script := make(map[string]any) + addEnclaveInt(script, "maxScriptBytes", enclave.Script.MaxScriptBytes) + values["script"] = script + } else { + agent := make(map[string]any) + addEnclaveString(agent, "engine", enclave.Agent.Engine) + addEnclaveString(agent, "profile", enclave.Agent.Profile) + addEnclaveString(agent, "model", enclave.Agent.Model) + addEnclaveInt(agent, "maxTaskBytes", enclave.Agent.MaxTaskBytes) + addEnclaveInt(agent, "maxModelRequests", enclave.Agent.MaxModelRequests) + addEnclaveInt(agent, "maxModelTokens", enclave.Agent.MaxModelTokens) + values["agent"] = agent + } + result = append(result, values) + } + return result +} + +func addEnclaveString(values map[string]any, key, value string) { + if value != "" { + values[key] = value + } +} + +func addEnclaveInt(values map[string]any, key string, value int) { + if value != 0 { + values[key] = value + } +} + +func writeEnclaveMCPJSON(yaml *strings.Builder, workflowData *WorkflowData, isLast bool) { + fmt.Fprintf(yaml, " %q: {\n", enclaveMCPServerName) + yaml.WriteString(" \"type\": \"http\",\n") + fmt.Fprintf(yaml, " \"url\": %q,\n", enclaveMCPUpstreamURL) + fmt.Fprintf(yaml, " \"headers\": {\"Authorization\": \"Bearer \\${%s}\"},\n", enclaveMCPCapabilityEnv) + fmt.Fprintf(yaml, " \"tools\": [") + for i, tool := range enabledEnclaveTools(workflowData) { + if i > 0 { + yaml.WriteString(", ") + } + fmt.Fprintf(yaml, "%q", tool) + } + yaml.WriteString("],\n") + fmt.Fprintf(yaml, " \"connectTimeout\": %d,\n", enclaveMCPConnectTimeout) + fmt.Fprintf(yaml, " \"toolTimeout\": %d\n", enclaveToolTimeout(workflowData)) + yaml.WriteString(" }") + if !isLast { + yaml.WriteString(",") + } + yaml.WriteString("\n") +} + +func writeEnclaveMCPTOML(yaml *strings.Builder, workflowData *WorkflowData) { + yaml.WriteString(" \n") + fmt.Fprintf(yaml, " [mcp_servers.%s]\n", enclaveMCPServerName) + yaml.WriteString(" type = \"http\"\n") + fmt.Fprintf(yaml, " url = %q\n", enclaveMCPUpstreamURL) + fmt.Fprintf(yaml, " headers = { Authorization = \"Bearer $%s\" }\n", enclaveMCPCapabilityEnv) + fmt.Fprintf(yaml, " tools = [") + for i, tool := range enabledEnclaveTools(workflowData) { + if i > 0 { + yaml.WriteString(", ") + } + fmt.Fprintf(yaml, "%q", tool) + } + yaml.WriteString("]\n") + fmt.Fprintf(yaml, " connectTimeout = %d\n", enclaveMCPConnectTimeout) + fmt.Fprintf(yaml, " toolTimeout = %d\n", enclaveToolTimeout(workflowData)) +} diff --git a/pkg/workflow/enclaves_test.go b/pkg/workflow/enclaves_test.go new file mode 100644 index 00000000000..115856303ed --- /dev/null +++ b/pkg/workflow/enclaves_test.go @@ -0,0 +1,249 @@ +package workflow + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/github/gh-aw/pkg/stringutil" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func enclaveWorkflowData(script, agent bool, scriptTimeout, agentTimeout int) *WorkflowData { + data := &WorkflowData{ + Tools: map[string]any{}, + SandboxConfig: &SandboxConfig{ + Agent: &AgentSandboxConfig{ + ID: "awf", + NetworkIsolation: true, + }, + }, + NetworkPermissions: &NetworkPermissions{ + Firewall: &FirewallConfig{Enabled: true}, + }, + } + if script { + data.Enclaves = append(data.Enclaves, &EnclaveConfig{ + Script: &ScriptEnclaveConfig{}, Timeout: scriptTimeout, Repos: enclaveTestRepos(), + }) + } + if agent { + data.Enclaves = append(data.Enclaves, &EnclaveConfig{ + Agent: &AgentEnclaveConfig{Model: "gpt-5"}, Timeout: agentTimeout, Repos: enclaveTestRepos(), + }) + } + return data +} + +func enclaveTestRepos() []*EnclaveRepository { + return []*EnclaveRepository{{ + Repo: "octo-org/private-service", Sensitivity: "confidential", + }} +} + +func TestEnabledEnclaveToolsAndTimeout(t *testing.T) { + tests := []struct { + name string + script, agent bool + scriptTime, agentTime int + wantTools []string + wantTimeout int + }{ + {"script only defaults cover timing bucket", true, false, 0, 0, []string{"enclave_run_script"}, 630}, + {"agent only defaults cover timing bucket", false, true, 0, 0, []string{"enclave_run_agent"}, 630}, + {"45 second custom timeout covers timing bucket", true, false, 45, 0, []string{"enclave_run_script"}, 630}, + {"540 second maximum timeout covers timing bucket", false, true, 0, 540, []string{"enclave_run_agent"}, 630}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + data := enclaveWorkflowData(tt.script, tt.agent, tt.scriptTime, tt.agentTime) + assert.Equal(t, tt.wantTools, enabledEnclaveTools(data)) + assert.Equal(t, tt.wantTimeout, enclaveToolTimeout(data)) + assert.Contains(t, collectMCPTools(data), enclaveMCPServerName) + }) + } + + disabled := enclaveWorkflowData(false, false, 0, 0) + assert.Empty(t, enabledEnclaveTools(disabled)) + assert.NotContains(t, collectMCPTools(disabled), enclaveMCPServerName) +} + +func TestValidateEnclavesRequiresNetworkIsolation(t *testing.T) { + data := enclaveWorkflowData(true, false, 30, 0) + data.SandboxConfig.Agent.NetworkIsolation = false + err := validateEnclavesConfig(data) + require.Error(t, err) + assert.Contains(t, err.Error(), "requires AWF network isolation") +} + +func TestValidateEnclavesRejectsBoundedQueries(t *testing.T) { + data := enclaveWorkflowData(true, false, 30, 0) + data.ParsedTools = &ToolsConfig{ + GitHub: &GitHubToolConfig{ + BoundedQueries: &BoundedQueriesConfig{}, + }, + } + err := validateEnclavesConfig(data) + require.Error(t, err) + assert.Contains(t, err.Error(), "cannot be combined with tools.github.bounded-queries") +} + +func TestValidateEnclavesRejectsDuplicateTypes(t *testing.T) { + data := enclaveWorkflowData(true, false, 30, 0) + data.Enclaves = append(data.Enclaves, &EnclaveConfig{ + Script: &ScriptEnclaveConfig{}, Repos: enclaveTestRepos(), + }) + err := validateEnclavesConfig(data) + require.Error(t, err) + assert.Contains(t, err.Error(), `duplicate executor type "script"`) +} + +func TestValidateEnclavesRequiresConsistentRepositorySensitivity(t *testing.T) { + data := enclaveWorkflowData(true, true, 30, 120) + data.Enclaves[1].Repos[0].Sensitivity = "sealed" + err := validateEnclavesConfig(data) + require.Error(t, err) + assert.Contains(t, err.Error(), "must use the same sensitivity across enclave types") +} + +func TestValidateEnclavesRequiresAgentModelOnly(t *testing.T) { + data := enclaveWorkflowData(false, true, 0, 120) + data.Enclaves[0].Agent.Model = "" + err := validateEnclavesConfig(data) + require.Error(t, err) + assert.Contains(t, err.Error(), "agent.model is required") + + script := enclaveWorkflowData(true, false, 30, 0) + assert.NoError(t, validateEnclavesConfig(script)) +} + +func TestParseTopLevelKeyedEnclaves(t *testing.T) { + config, err := ParseFrontmatterConfig(map[string]any{ + "enclaves": []any{ + map[string]any{ + "script": nil, + "repos": []any{ + map[string]any{"repo": "octo-org/private-service", "sensitivity": "confidential"}, + }, + "timeout": 45, + }, + }, + }) + require.NoError(t, err) + require.Len(t, config.Enclaves, 1) + require.NotNil(t, config.Enclaves[0].Script) + assert.Equal(t, 45, config.Enclaves[0].Timeout) + require.Len(t, config.Enclaves[0].Repos, 1) +} + +func TestEnclaveConfigRejectsAmbiguousDiscriminator(t *testing.T) { + data := enclaveWorkflowData(false, false, 0, 0) + data.Enclaves = EnclavesConfig{{ + Script: &ScriptEnclaveConfig{}, + Agent: &AgentEnclaveConfig{Model: "gpt-5"}, + Repos: enclaveTestRepos(), + }} + err := validateEnclavesConfig(data) + require.Error(t, err) + assert.Contains(t, err.Error(), "exactly one of script or agent") +} + +func TestBuildAWFConfigJSONEnclaves(t *testing.T) { + data := enclaveWorkflowData(true, true, 45, 180) + configJSON, err := BuildAWFConfigJSON(AWFCommandConfig{ + EngineName: "copilot", WorkflowData: data, + }) + require.NoError(t, err) + + var config map[string]any + require.NoError(t, json.Unmarshal([]byte(configJSON), &config)) + enclaves := config["enclaves"].([]any) + script := enclaves[0].(map[string]any) + agent := enclaves[1].(map[string]any) + scriptConfig := script["script"].(map[string]any) + agentConfig := agent["agent"].(map[string]any) + assert.Empty(t, scriptConfig) + assert.InDelta(t, 45, script["timeout"], 0) + assert.Equal(t, "gpt-5", agentConfig["model"]) + assert.Contains(t, script, "repos") + assert.NotContains(t, script, "enabled") + assert.NotContains(t, script, "network") + assert.NotContains(t, script, "interpreter") + assert.Equal(t, []any{"awmg-mcpg"}, config["network"].(map[string]any)["topologyAttach"]) + assert.NotContains(t, configJSON, "boundedQueries") + assert.NotContains(t, configJSON, "boundedAgents") +} + +func TestGenerateEnclaveGatewayContract(t *testing.T) { + data := enclaveWorkflowData(true, true, 45, 180) + ensureDefaultMCPGatewayConfig(data) + var output strings.Builder + require.NoError(t, generateMCPGatewaySetup( + &output, data.Tools, []string{enclaveMCPServerName}, NewCopilotEngine(), data, false, nil, + )) + generated := output.String() + + assert.Contains(t, generated, `"awf-enclave": {`) + assert.Contains(t, generated, `"url": "http://awf-enclave-mcp:8080/mcp"`) + assert.Contains(t, generated, `"connectTimeout": 120`) + assert.Contains(t, generated, `"toolTimeout": 630`) + assert.Contains(t, generated, `"tools": ["enclave_run_script", "enclave_run_agent"]`) + assert.Contains(t, generated, `Bearer \${AWF_ENCLAVE_MCP_CAPABILITY}`) + assert.Contains(t, generated, `openssl rand -hex 32`) + assert.Contains(t, generated, `::add-mask::${AWF_ENCLAVE_MCP_CAPABILITY}`) + assert.Contains(t, generated, `--network bridge`) + assert.Contains(t, generated, `--label com.github.gh-aw.mcpg.run=`) + assert.Contains(t, generated, `${AWF_ENCLAVE_MCP_GATEWAY_IDENTITY}`) + assert.Contains(t, generated, `-e AWF_ENCLAVE_MCP_CAPABILITY`) + assert.Contains(t, generated, `AWF_ENCLAVE_MCP_GATEWAY_ENDPOINT="http://localhost:${MCP_GATEWAY_PORT}/mcp/awf-enclave"`) + assert.NotRegexp(t, `AWF_ENCLAVE_MCP_CAPABILITY=[0-9a-f]{64}`, generated) + + excluded := ComputeAWFExcludeEnvVarNames(data, nil) + assert.Contains(t, excluded, enclaveMCPCapabilityEnv) + assert.Contains(t, excluded, enclaveMCPGatewayIdentityEnv) +} + +func TestCompileEnclaveStartupOrdering(t *testing.T) { + tmp := t.TempDir() + workflowPath := filepath.Join(tmp, "enclave.md") + content := `--- +on: workflow_dispatch +strict: false +network: defaults +engine: copilot +sandbox: + agent: + id: awf + sudo: false + version: latest +enclaves: + - script: + repos: + - repo: octo-org/private-service + sensitivity: confidential + timeout: 45 +--- + +Use the enclave script executor. +` + require.NoError(t, os.WriteFile(workflowPath, []byte(content), 0o600)) + compiler := NewCompiler() + compiler.SetSkipValidation(true) + require.NoError(t, compiler.CompileWorkflow(workflowPath)) + lockBytes, err := os.ReadFile(stringutil.MarkdownToLockFile(workflowPath)) + require.NoError(t, err) + lock := string(lockBytes) + + gateway := strings.Index(lock, "- name: Start MCP Gateway") + awf := strings.Index(lock, "awf --config") + require.Greater(t, gateway, -1) + require.Greater(t, awf, -1) + assert.Less(t, gateway, awf) + assert.Contains(t, lock, `"awf-enclave"`) + assert.Contains(t, lock, `\"enclaves\":[{\"repos\":[{\"repo\":\"octo-org/private-service\",\"sensitivity\":\"confidential\"}],\"script\":{},\"timeout\":45}]`) + assert.NotContains(t, lock, "Start Enclave MCP") + assert.NotContains(t, lock, "start_enclave") +} diff --git a/pkg/workflow/frontmatter_extraction_security.go b/pkg/workflow/frontmatter_extraction_security.go index d303c84599e..9c060f104c1 100644 --- a/pkg/workflow/frontmatter_extraction_security.go +++ b/pkg/workflow/frontmatter_extraction_security.go @@ -121,9 +121,9 @@ func (c *Compiler) extractSandboxConfig(frontmatter map[string]any) *SandboxConf config.MCP = c.extractMCPGatewayConfig(mcpVal) } - // If we found agent field, return the new format config - if config.Agent != nil { - frontmatterExtractionSecurityLog.Print("Sandbox configured with new format (agent)") + // Agent and MCP select the new sandbox format. + if config.Agent != nil || config.MCP != nil { + frontmatterExtractionSecurityLog.Print("Sandbox configured with new format") return config } diff --git a/pkg/workflow/frontmatter_serialization.go b/pkg/workflow/frontmatter_serialization.go index 7996eb3a7c3..44ab72ab1f0 100644 --- a/pkg/workflow/frontmatter_serialization.go +++ b/pkg/workflow/frontmatter_serialization.go @@ -120,6 +120,9 @@ func (fc *FrontmatterConfig) ToMap() map[string]any { // Convert MCPScriptsConfig to map - would need a ToMap method result["mcp-scripts"] = fc.MCPScripts } + if len(fc.Enclaves) > 0 { + result["enclaves"] = fc.Enclaves + } // Event and trigger configuration if fc.On != nil { diff --git a/pkg/workflow/frontmatter_types.go b/pkg/workflow/frontmatter_types.go index e0fde496cc1..aa367c6f477 100644 --- a/pkg/workflow/frontmatter_types.go +++ b/pkg/workflow/frontmatter_types.go @@ -344,6 +344,7 @@ type FrontmatterConfig struct { Jobs map[string]any `json:"jobs,omitempty"` // Custom workflow jobs (too dynamic to type) SafeOutputs *SafeOutputsConfig `json:"safe-outputs,omitempty"` MCPScripts *MCPScriptsConfig `json:"mcp-scripts,omitempty"` + Enclaves EnclavesConfig `json:"enclaves,omitempty"` PermissionsTyped *PermissionsConfig `json:"-"` // New typed field (not in JSON to avoid conflict) // Event and trigger configuration diff --git a/pkg/workflow/mcp_renderer.go b/pkg/workflow/mcp_renderer.go index 49f7f5cd4e0..46bc0d0c922 100644 --- a/pkg/workflow/mcp_renderer.go +++ b/pkg/workflow/mcp_renderer.go @@ -175,6 +175,10 @@ func RenderJSONMCPConfig( if options.Renderers.RenderMCPScripts != nil { options.Renderers.RenderMCPScripts(&configBuilder, workflowData.MCPScripts, isLast) } + case enclaveMCPServerName: + if options.Renderers.RenderEnclave != nil { + options.Renderers.RenderEnclave(&configBuilder, workflowData, isLast) + } default: // Handle custom MCP tools using shared helper HandleCustomMCPToolInSwitch(&configBuilder, toolName, tools, isLast, options.Renderers.RenderCustomMCPConfig) @@ -244,7 +248,7 @@ func RenderJSONMCPConfig( // The config is rendered inside an unquoted bash heredoc; unvalidated IDs // containing shell metacharacters (e.g. $(cmd), `cmd`) would be expanded. if !isSafeMCPServerID(serverID) { - return fmt.Errorf("private-to-public-flows: server ID %q contains characters that are unsafe for shell heredoc emission; IDs must match [A-Za-z0-9_-]+", serverID) + return fmt.Errorf("private-to-public-flows: server ID %q contains characters that are unsafe for shell heredoc emission; IDs must match [A-Za-z0-9_-]+. Example:\n\nfirewall:\n private-to-public-flows:\n allowed-server-ids:\n - my-safe-server", serverID) } fmt.Fprintf(&configBuilder, "%q", serverID) } diff --git a/pkg/workflow/mcp_renderer_factory.go b/pkg/workflow/mcp_renderer_factory.go index 8984f9ef87a..ed96bf217ad 100644 --- a/pkg/workflow/mcp_renderer_factory.go +++ b/pkg/workflow/mcp_renderer_factory.go @@ -192,6 +192,9 @@ func buildStandardJSONMCPRenderers( RenderMCPScripts: func(yaml *strings.Builder, mcpScripts *MCPScriptsConfig, isLast bool) { createRenderer(isLast).RenderMCPScriptsMCP(yaml, mcpScripts, workflowData) }, + RenderEnclave: func(yaml *strings.Builder, workflowData *WorkflowData, isLast bool) { + writeEnclaveMCPJSON(yaml, workflowData, isLast) + }, RenderCustomMCPConfig: renderCustom, } } diff --git a/pkg/workflow/mcp_renderer_types.go b/pkg/workflow/mcp_renderer_types.go index 625db122e52..a56848bc8f3 100644 --- a/pkg/workflow/mcp_renderer_types.go +++ b/pkg/workflow/mcp_renderer_types.go @@ -43,6 +43,7 @@ type MCPToolRenderers struct { RenderAgenticWorkflows func(yaml *strings.Builder, isLast bool) RenderSafeOutputs func(yaml *strings.Builder, isLast bool, workflowData *WorkflowData) RenderMCPScripts func(yaml *strings.Builder, mcpScripts *MCPScriptsConfig, isLast bool) + RenderEnclave func(yaml *strings.Builder, workflowData *WorkflowData, isLast bool) RenderCustomMCPConfig RenderCustomMCPToolConfigHandler } diff --git a/pkg/workflow/mcp_setup_gateway.go b/pkg/workflow/mcp_setup_gateway.go index c141fa32af1..8c1970f9c69 100644 --- a/pkg/workflow/mcp_setup_gateway.go +++ b/pkg/workflow/mcp_setup_gateway.go @@ -237,6 +237,22 @@ func writeMCPGatewayExports(yaml *strings.Builder, opts writeMCPGatewayExportsOp // Allow read-write access to the host paths our built-in MCP servers mount // (workspace, safe-outputs runtime dir, temp dir); see buildMCPGatewayAllowedMountRoots. yaml.WriteString(" export MCP_GATEWAY_ALLOWED_MOUNT_ROOTS=\"" + buildMCPGatewayAllowedMountRoots(tools, gatewayConfig) + "\"\n") + if enclavesEnabled(workflowData) { + yaml.WriteString(" AWF_ENCLAVE_MCP_CAPABILITY=$(openssl rand -hex 32)\n") + yaml.WriteString(" echo \"::add-mask::${AWF_ENCLAVE_MCP_CAPABILITY}\"\n") + yaml.WriteString(" export AWF_ENCLAVE_MCP_CAPABILITY\n") + yaml.WriteString(" export AWF_ENCLAVE_MCP_GATEWAY_IDENTITY=\"gh-aw-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}-${GITHUB_JOB}\"\n") + yaml.WriteString(" export AWF_ENCLAVE_MCP_GATEWAY_CONTAINER=\"awmg-mcpg\"\n") + yaml.WriteString(" export AWF_ENCLAVE_MCP_GATEWAY_ENDPOINT=\"http://localhost:${MCP_GATEWAY_PORT}/mcp/awf-enclave\"\n") + yaml.WriteString(" export AWF_ENCLAVE_MCP_READINESS_TIMEOUT_MS=\"120000\"\n") + yaml.WriteString(" {\n") + yaml.WriteString(" printf '%s=%s\\n' AWF_ENCLAVE_MCP_CAPABILITY \"$AWF_ENCLAVE_MCP_CAPABILITY\"\n") + yaml.WriteString(" printf '%s=%s\\n' AWF_ENCLAVE_MCP_GATEWAY_IDENTITY \"$AWF_ENCLAVE_MCP_GATEWAY_IDENTITY\"\n") + yaml.WriteString(" printf '%s=%s\\n' AWF_ENCLAVE_MCP_GATEWAY_CONTAINER \"$AWF_ENCLAVE_MCP_GATEWAY_CONTAINER\"\n") + yaml.WriteString(" printf '%s=%s\\n' AWF_ENCLAVE_MCP_GATEWAY_ENDPOINT \"$AWF_ENCLAVE_MCP_GATEWAY_ENDPOINT\"\n") + yaml.WriteString(" printf '%s=%s\\n' AWF_ENCLAVE_MCP_READINESS_TIMEOUT_MS \"$AWF_ENCLAVE_MCP_READINESS_TIMEOUT_MS\"\n") + yaml.WriteString(" } >> \"$GITHUB_ENV\"\n") + } yaml.WriteString(" export DEBUG=\"*\"\n") yaml.WriteString(" \n") yaml.WriteString(" export GH_AW_ENGINE=\"" + engine.GetID() + "\"\n") @@ -313,6 +329,9 @@ func buildMCPGatewayContainerCommand(opts buildMCPGatewayContainerCommandOptions containerCmd.WriteString(" --network host") } containerCmd.WriteString(" --name awmg-mcpg") + if enclavesEnabled(workflowData) { + containerCmd.WriteString(" --label " + enclaveMCPGatewayRunLabel + "=${AWF_ENCLAVE_MCP_GATEWAY_IDENTITY}") + } if !isAWFNetworkIsolationEnabled(workflowData) { containerCmd.WriteString(" --add-host host.docker.internal:127.0.0.1") } else if shouldRewriteLocalhostToDocker(workflowData) { @@ -565,6 +584,9 @@ func extractMCPVolumeArgMounts(argsRaw any) []string { } func appendMCPGatewayConditionalEnvFlags(containerCmd *strings.Builder, workflowData *WorkflowData, engine CodingAgentEngine, hasGitHub bool, githubTool map[string]any, tools map[string]any) { + if enclavesEnabled(workflowData) { + containerCmd.WriteString(" -e " + enclaveMCPCapabilityEnv) + } if hasGitHub && getGitHubType(githubTool) == GitHubMCPModeRemote && engine.GetID() == "copilot" { containerCmd.WriteString(" -e GITHUB_PERSONAL_ACCESS_TOKEN") } diff --git a/pkg/workflow/mcp_setup_generator.go b/pkg/workflow/mcp_setup_generator.go index 08f0e7faff9..31c81563f71 100644 --- a/pkg/workflow/mcp_setup_generator.go +++ b/pkg/workflow/mcp_setup_generator.go @@ -168,6 +168,9 @@ func collectMCPTools(workflowData *WorkflowData) []string { if IsMCPScriptsEnabled(workflowData.MCPScripts) { mcpTools = append(mcpTools, "mcp-scripts") } + if enclavesEnabled(workflowData) { + mcpTools = append(mcpTools, enclaveMCPServerName) + } return mcpTools } diff --git a/pkg/workflow/schemas/awf-config.schema.json b/pkg/workflow/schemas/awf-config.schema.json index 8348151fb76..f2847eeb80b 100644 --- a/pkg/workflow/schemas/awf-config.schema.json +++ b/pkg/workflow/schemas/awf-config.schema.json @@ -767,6 +767,221 @@ } } }, + "enclaves": { + "type": "array", + "description": "Unified private-repository script and agent enclaves. Repositories shared across entries use one per-run information budget, and AWF exposes each entry only through its owned MCP server and the compiler-launched trusted mcpg gateway.", + "minItems": 1, + "maxItems": 2, + "items": { + "oneOf": [ + { + "type": "object", + "additionalProperties": false, + "required": ["script", "repos"], + "properties": { + "script": { + "type": "object", + "additionalProperties": false, + "properties": { + "maxScriptBytes": { + "type": "integer", + "minimum": 1, + "maximum": 65536, + "default": 65536 + } + } + }, + "repos": { + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "additionalProperties": false, + "required": ["repo", "sensitivity"], + "properties": { + "repo": { + "type": "string", + "maxLength": 140, + "pattern": "^[A-Za-z0-9][A-Za-z0-9-]{0,38}/[A-Za-z0-9._-]{1,100}$" + }, + "sensitivity": { + "type": "string", + "enum": ["public", "internal", "confidential", "sealed"] + } + } + } + }, + "runtime": { + "type": "string", + "enum": ["docker", "gvisor", "sbx"], + "default": "docker" + }, + "image": { + "type": "string", + "minLength": 1, + "maxLength": 500 + }, + "timeout": { + "type": "integer", + "minimum": 1, + "maximum": 540, + "default": 30 + }, + "memoryLimit": { + "type": "string", + "pattern": "^[1-9][0-9]*[bkmgBKMG]$", + "default": "512m" + }, + "cpuLimit": { + "type": "string", + "pattern": "^(?:[0-9]{1,2})(?:\\.[0-9]{1,3})?$", + "default": "1" + }, + "pidsLimit": { + "type": "integer", + "minimum": 1, + "maximum": 4096, + "default": 128 + }, + "tmpfsLimit": { + "type": "string", + "pattern": "^[1-9][0-9]*[bkmgBKMG]$", + "default": "64m" + }, + "maxOutputBytes": { + "type": "integer", + "minimum": 1, + "maximum": 8192, + "default": 8192 + }, + "maxInvocations": { + "type": "integer", + "minimum": 1, + "maximum": 10000, + "default": 32 + } + } + }, + { + "type": "object", + "additionalProperties": false, + "required": ["agent", "repos"], + "properties": { + "agent": { + "type": "object", + "additionalProperties": false, + "required": ["model"], + "properties": { + "engine": { + "type": "string", + "enum": ["copilot", "claude", "codex", "gemini"], + "default": "copilot" + }, + "profile": { + "type": "string", + "enum": ["openai", "anthropic"], + "default": "openai" + }, + "model": { + "type": "string", + "minLength": 1, + "maxLength": 200, + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:@/-]{0,199}$" + }, + "maxTaskBytes": { + "type": "integer", + "minimum": 1, + "maximum": 65536, + "default": 4096 + }, + "maxModelRequests": { + "type": "integer", + "minimum": 1, + "maximum": 64, + "default": 8 + }, + "maxModelTokens": { + "type": "integer", + "minimum": 1, + "maximum": 32768, + "default": 1024 + } + } + }, + "repos": { + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "additionalProperties": false, + "required": ["repo", "sensitivity"], + "properties": { + "repo": { + "type": "string", + "maxLength": 140, + "pattern": "^[A-Za-z0-9][A-Za-z0-9-]{0,38}/[A-Za-z0-9._-]{1,100}$" + }, + "sensitivity": { + "type": "string", + "enum": ["public", "internal", "confidential", "sealed"] + } + } + } + }, + "runtime": { + "type": "string", + "enum": ["docker", "gvisor", "sbx"], + "default": "docker" + }, + "image": { + "type": "string", + "minLength": 1, + "maxLength": 500 + }, + "timeout": { + "type": "integer", + "minimum": 1, + "maximum": 540, + "default": 120 + }, + "memoryLimit": { + "type": "string", + "pattern": "^[1-9][0-9]*[bkmgBKMG]$", + "default": "512m" + }, + "cpuLimit": { + "type": "string", + "pattern": "^(?:[0-9]{1,2})(?:\\.[0-9]{1,3})?$", + "default": "1" + }, + "pidsLimit": { + "type": "integer", + "minimum": 1, + "maximum": 4096, + "default": 128 + }, + "tmpfsLimit": { + "type": "string", + "pattern": "^[1-9][0-9]*[bkmgBKMG]$", + "default": "64m" + }, + "maxOutputBytes": { + "type": "integer", + "minimum": 1, + "maximum": 8192, + "default": 8192 + }, + "maxInvocations": { + "type": "integer", + "minimum": 1, + "maximum": 1000, + "default": 8 + } + } + } + ] + } + }, "boundedQueries": { "type": "object", "description": "Bounded-query sandbox configuration. When enabled, AWF stages an immutable seed per configured private repository, starts an offline broker (network_mode: none), and exposes a fixed `bounded-query` CLI plus a generated skill to the agent. See docs/awf-config-spec.md §14.", diff --git a/pkg/workflow/schemas/mcp-gateway-config.schema.json b/pkg/workflow/schemas/mcp-gateway-config.schema.json index d08d8f91a98..6edc303d139 100644 --- a/pkg/workflow/schemas/mcp-gateway-config.schema.json +++ b/pkg/workflow/schemas/mcp-gateway-config.schema.json @@ -145,6 +145,18 @@ }, "default": ["*"] }, + "connectTimeout": { + "type": "integer", + "description": "Per-transport timeout in seconds while connecting to an HTTP MCP upstream.", + "minimum": 1, + "default": 30 + }, + "toolTimeout": { + "type": "integer", + "description": "Per-server timeout in seconds for a tool invocation.", + "minimum": 1, + "default": 60 + }, "env": { "type": "object", "description": "Environment variables to pass through for variable resolution. Values may contain variable expressions using '${VARIABLE_NAME}' syntax, which will be resolved from the process environment.", diff --git a/pkg/workflow/workflow_builder.go b/pkg/workflow/workflow_builder.go index 2dcc5ccc556..a8548365119 100644 --- a/pkg/workflow/workflow_builder.go +++ b/pkg/workflow/workflow_builder.go @@ -72,6 +72,7 @@ func (c *Compiler) buildInitialWorkflowData( NetworkPermissions: engineSetup.networkPermissions, SandboxConfig: applySandboxDefaults(engineSetup.sandboxConfig, engineSetup.engineConfig), RunnerConfig: extractRunnerConfig(result.Frontmatter), + Enclaves: extractEnclavesConfig(result.Frontmatter), NeedsTextOutput: toolsResult.needsTextOutput, ToolsTimeout: toolsResult.toolsTimeout, ToolsStartupTimeout: toolsResult.toolsStartupTimeout, @@ -214,6 +215,22 @@ func (c *Compiler) buildInitialWorkflowData( return workflowData } +func extractEnclavesConfig(frontmatter map[string]any) EnclavesConfig { + raw, ok := frontmatter["enclaves"] + if !ok { + return nil + } + data, err := json.Marshal(raw) + if err != nil { + return EnclavesConfig{nil} + } + var enclaves EnclavesConfig + if err := json.Unmarshal(data, &enclaves); err != nil { + return EnclavesConfig{nil} + } + return enclaves +} + func extractLSPConfig(parsedFrontmatter *FrontmatterConfig, frontmatter map[string]any) map[string]LSPServerConfig { if parsedFrontmatter != nil && len(parsedFrontmatter.LSP) > 0 { return parsedFrontmatter.LSP diff --git a/pkg/workflow/workflow_data.go b/pkg/workflow/workflow_data.go index be1f345a00c..d52980d1c25 100644 --- a/pkg/workflow/workflow_data.go +++ b/pkg/workflow/workflow_data.go @@ -135,6 +135,7 @@ type WorkflowData struct { SafeOutputs *SafeOutputsConfig // output configuration for automatic output routes SafeOutputsInputEnvVars map[string]string // GH_AW_INPUT_* env vars referenced by safe-outputs config; populated during MCP setup generation so renderers can forward them to the nested container MCPScripts *MCPScriptsConfig // mcp-scripts configuration for custom MCP tools + Enclaves EnclavesConfig // AWF-owned private repository enclave executors LabelNames []string // label names that must match for pull_request_target labeled events (on.labels) Roles []string // permission levels required to trigger workflow Bots []string // allow list of bot identifiers that can trigger workflow