From a3197546b2f5ca89ccf8ad3d23ed95481c807930 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 30 Jul 2026 14:06:23 +0000 Subject: [PATCH 1/5] Initial plan From 2ae267a70140239db7b84769250be52a1f739ddc Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 30 Jul 2026 14:31:35 +0000 Subject: [PATCH 2/5] feat: support bounded queries in workflow frontmatter and AWF config Co-authored-by: lpcox <15877973+lpcox@users.noreply.github.com> --- .github/aw/syntax-agentic.md | 20 + pkg/constants/spec_test.go | 2 + pkg/constants/version_constants.go | 5 + pkg/workflow/awf_config.go | 93 ++++ pkg/workflow/awf_helpers.go | 6 + pkg/workflow/bounded_queries_test.go | 495 ++++++++++++++++++++ pkg/workflow/sandbox.go | 96 +++- pkg/workflow/sandbox_validation.go | 196 ++++++++ pkg/workflow/schemas/awf-config.schema.json | 59 +++ 9 files changed, 954 insertions(+), 18 deletions(-) create mode 100644 pkg/workflow/bounded_queries_test.go diff --git a/.github/aw/syntax-agentic.md b/.github/aw/syntax-agentic.md index 09d9a38c832..67971a41b0e 100644 --- a/.github/aw/syntax-agentic.md +++ b/.github/aw/syntax-agentic.md @@ -314,6 +314,26 @@ description: Agentic workflow specific frontmatter fields for GitHub Agentic Wor - **`sandbox.agent.sudo`** (boolean) controls whether AWF runs in root mode. Default is `false`: AWF runs rootless in network-isolation egress mode (`--network-isolation`), with MCP sidecars attached as bridge containers on the internal `awf-net` network. Set `sudo: true` for the legacy root mode; in strict mode explicit `sudo: true` is an error (warning otherwise). - **`sandbox.agent.runtime`** (string) selects an extra-isolation container runtime for the agent: `gvisor` (runs under gVisor's `runsc` for kernel-level isolation) or `docker-sbx` (Docker sbx microVM with KVM hypervisor-level isolation; needs `DOCKER_PAT`/`DOCKER_USERNAME` secrets and a KVM-capable runner). Both require `sudo: true` and are incompatible with `runner.topology: arc-dind`. + - **`sandbox.agent.bounded-queries`** (object, AWF v0.28.0+) configures the AWF bounded-query subsystem for cross-repository private data access. When present, the agent may answer finite, pre-approved questions about the listed repositories using the generated `bounded-query` skill — without receiving raw source code. This is the preferred pattern for cross-repository workflows. All optional fields use AWF defaults when omitted. + + ```yaml + sandbox: + agent: + id: awf + bounded-queries: + private-repos: + - repo: my-org/public-docs + sensitivity: public # public | internal | confidential | sealed + - repo: my-org/internal-service + sensitivity: internal + runtime: docker # optional; default: AWF default + timeout: 30 # optional; seconds; default: AWF default + memory-limit: 512m # optional; e.g. 512m, 2g; default: AWF default + interpreter: python3 # optional; default: AWF default + max-invocations: 32 # optional; default: AWF default + ``` + + Sensitivity levels: `public` (no restrictions), `internal` (internal-only audiences), `confidential` (restricted within org), `sealed` (highest restriction). The staging credential used to access private repositories must remain host-side and is never written to the lock file or exposed to the agent. Use bounded queries when the question has a finite, bounded answer; prefer this over granting a cross-repository token or checking out the private repository into the primary workspace. - **Strict mode**: `sandbox.agent` blocks without an explicit `id: awf` are rejected in strict mode. Any non-nil, non-disabled agent config without `id`/`type` defaults to AWF at runtime. - **`tools:`** - Tool configuration for the coding agent (`github`, `agentic-workflows`, `edit`, `web-fetch`, `web-search`, `bash`, `playwright`, custom MCP server names, plus `timeout`/`startup-timeout`/`cli-proxy`). See [syntax-tools-imports.md](syntax-tools-imports.md#tool-configuration) for the full schema (GitHub `mode`/`toolsets`/integrity fields, bash allowlist decision rule, Playwright CLI mode). diff --git a/pkg/constants/spec_test.go b/pkg/constants/spec_test.go index 9367e612b8c..35c3b24f6f6 100644 --- a/pkg/constants/spec_test.go +++ b/pkg/constants/spec_test.go @@ -360,6 +360,8 @@ func TestSpec_VersionConstraints_MinVersionValues(t *testing.T) { {name: "AWFTokenSteeringMinVersion", constant: constants.AWFTokenSteeringMinVersion, expected: "v0.25.44"}, // From spec: CopilotNoAskUserMinVersion // "1.0.19" {name: "CopilotNoAskUserMinVersion", constant: constants.CopilotNoAskUserMinVersion, expected: "1.0.19"}, + // From spec: AWFBoundedQueriesMinVersion // "v0.28.0" + {name: "AWFBoundedQueriesMinVersion", constant: constants.AWFBoundedQueriesMinVersion, expected: "v0.28.0"}, } for _, tt := range tests { diff --git a/pkg/constants/version_constants.go b/pkg/constants/version_constants.go index 03da430efce..78123f4d035 100644 --- a/pkg/constants/version_constants.go +++ b/pkg/constants/version_constants.go @@ -124,6 +124,11 @@ const AWFLegacySecurityMinVersion Version = "v0.27.32" // future release that adds apiProxy.providers to awf-config-schema.json. const AWFAPIProxyProvidersMinVersion Version = "v0.27.43" +// AWFBoundedQueriesMinVersion is the minimum AWF version that supports +// the boundedQueries section in awf-config.json. +// Workflows pinning an older AWF version must not emit this section. +const AWFBoundedQueriesMinVersion Version = "v0.28.0" + // DefaultGVisorVersion is the pinned gVisor release used by the compiler-generated // install step. A specific dated release name is used instead of "latest" to ensure // reproducible, verifiable installs. Each release provides SHA-512 files for diff --git a/pkg/workflow/awf_config.go b/pkg/workflow/awf_config.go index 1d6937fbe68..1bda036dd50 100644 --- a/pkg/workflow/awf_config.go +++ b/pkg/workflow/awf_config.go @@ -170,6 +170,10 @@ type AWFConfigFile struct { // APIProxy contains API proxy (LLM gateway) configuration. APIProxy *AWFAPIProxyConfig `json:"apiProxy,omitempty"` + // BoundedQueries configures the AWF bounded-query subsystem for approved + // cross-repository private data access. Omitted when not configured. + BoundedQueries *AWFBoundedQueriesConfig `json:"boundedQueries,omitempty"` + // Container contains container execution configuration. Container *AWFContainerConfig `json:"container,omitempty"` @@ -181,6 +185,50 @@ type AWFConfigFile struct { Chroot *AWFChrootConfig `json:"chroot,omitempty"` } +// AWFBoundedQueriesConfig is the "boundedQueries" section of the AWF config file. +// It controls the bounded-query subsystem that allows finite, pre-approved questions +// about private repositories. All optional fields are omitted when unset so that +// AWF remains the source of truth for default values. +type AWFBoundedQueriesConfig struct { + // Enabled must be true when boundedQueries is present in the config. + // gh-aw always sets this to true when the section is generated. + Enabled bool `json:"enabled"` + + // PrivateRepos is the list of private repositories approved for bounded-query access. + PrivateRepos []*AWFBoundedQueryPrivateRepo `json:"privateRepos,omitempty"` + + // Runtime is the container runtime for bounded-query script execution (e.g. "docker"). + // Optional; when omitted AWF uses its default. + Runtime string `json:"runtime,omitempty"` + + // Timeout is the maximum execution time in seconds for a single invocation. + // Optional; when omitted AWF uses its default. + Timeout int `json:"timeout,omitempty"` + + // MemoryLimit is the memory limit for bounded-query container execution (e.g. "512m"). + // Optional; when omitted AWF uses its default. + MemoryLimit string `json:"memoryLimit,omitempty"` + + // Interpreter is the script interpreter for bounded-query execution (e.g. "python3"). + // Optional; when omitted AWF uses its default. + Interpreter string `json:"interpreter,omitempty"` + + // MaxInvocations is the maximum number of bounded-query invocations per run. + // Optional; when omitted AWF uses its default. + MaxInvocations int `json:"maxInvocations,omitempty"` +} + +// AWFBoundedQueryPrivateRepo describes a single private repository approved for +// bounded-query access, with its confidentiality classification. +type AWFBoundedQueryPrivateRepo struct { + // Repo is the "owner/repo" slug of the approved private repository. + Repo string `json:"repo"` + + // Sensitivity is the confidentiality classification. + // Accepted values: "public", "internal", "confidential", "sealed". + Sensitivity string `json:"sensitivity"` +} + // AWFRunnerConfig is the "runner" section of the AWF config file. // It provides a single stable contract between gh-aw and AWF for runner topology // detection, letting AWF resolve all internal details (network isolation, sysroot @@ -689,6 +737,16 @@ func BuildAWFConfigJSON(config AWFCommandConfig) (string, error) { } awfConfigLog.Printf("Logging section: proxyLogsDir=%s, auditDir=%s", awfConfig.Logging.ProxyLogsDir, awfConfig.Logging.AuditDir) + // ── Bounded queries section ────────────────────────────────────────────── + if bq := extractBoundedQueriesConfig(config.WorkflowData); bq != nil { + if awfSupportsBoundedQueries(firewallConfig) { + awfConfig.BoundedQueries = bq + awfConfigLog.Printf("Bounded queries section: %d private repo(s)", len(bq.PrivateRepos)) + } else { + awfConfigLog.Printf("Skipping boundedQueries: AWF version %q requires at least %s", getAWFImageTag(firewallConfig), constants.AWFBoundedQueriesMinVersion) + } + } + jsonStr, err := jsonutil.MarshalCompactNoHTMLEscape(awfConfig) if err != nil { return "", fmt.Errorf("failed to marshal AWF config to JSON: %w", err) @@ -899,6 +957,41 @@ func extractModelCostProviders(workflowData *WorkflowData) map[string]any { return clone } +// extractBoundedQueriesConfig returns an AWFBoundedQueriesConfig populated from +// sandbox.agent.bounded-queries, or nil when the field is absent. +// Only fields explicitly set in frontmatter are included; optional fields that +// were not specified are omitted so that AWF remains the source of truth for defaults. +func extractBoundedQueriesConfig(workflowData *WorkflowData) *AWFBoundedQueriesConfig { + if workflowData == nil { + return nil + } + if workflowData.SandboxConfig == nil || workflowData.SandboxConfig.Agent == nil { + return nil + } + bq := workflowData.SandboxConfig.Agent.BoundedQueries + if bq == nil { + return nil + } + + awfBQ := &AWFBoundedQueriesConfig{ + Enabled: true, + Runtime: bq.Runtime, + Timeout: bq.Timeout, + MemoryLimit: bq.MemoryLimit, + Interpreter: bq.Interpreter, + MaxInvocations: bq.MaxInvocations, + } + + for _, r := range bq.PrivateRepos { + awfBQ.PrivateRepos = append(awfBQ.PrivateRepos, &AWFBoundedQueryPrivateRepo{ + Repo: r.Repo, + Sensitivity: r.Sensitivity, + }) + } + + return awfBQ +} + // getRunnerTopology extracts the runner topology string from WorkflowData. // Returns an empty string when no topology is configured. func getRunnerTopology(workflowData *WorkflowData) string { diff --git a/pkg/workflow/awf_helpers.go b/pkg/workflow/awf_helpers.go index 22e6a7ee1c8..796d2644312 100644 --- a/pkg/workflow/awf_helpers.go +++ b/pkg/workflow/awf_helpers.go @@ -1099,6 +1099,12 @@ func awfSupportsAPIProxyProviders(firewallConfig *FirewallConfig) bool { return awfVersionAtLeast(firewallConfig, constants.AWFAPIProxyProvidersMinVersion) } +// awfSupportsBoundedQueries returns true when the effective AWF version supports +// the boundedQueries section in awf-config.json. +func awfSupportsBoundedQueries(firewallConfig *FirewallConfig) bool { + return awfVersionAtLeast(firewallConfig, constants.AWFBoundedQueriesMinVersion) +} + // buildArcDindChrootConfigPatchBody returns the Node.js command that patches the AWF // config file with chroot.binariesSourcePath and chroot.identity.*. It is designed to be // embedded inside a bash if-block that already guards on DOCKER_HOST=tcp://... diff --git a/pkg/workflow/bounded_queries_test.go b/pkg/workflow/bounded_queries_test.go new file mode 100644 index 00000000000..482a874f39e --- /dev/null +++ b/pkg/workflow/bounded_queries_test.go @@ -0,0 +1,495 @@ +//go:build !integration + +package workflow + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/github/gh-aw/pkg/constants" +) + +// TestBuildAWFConfigJSON_BoundedQueries verifies that bounded-queries frontmatter +// is translated to the correct boundedQueries AWF config JSON section. +func TestBuildAWFConfigJSON_BoundedQueries(t *testing.T) { + makeBaseConfig := func(bq *BoundedQueriesConfig) AWFCommandConfig { + return AWFCommandConfig{ + EngineName: "copilot", + AllowedDomains: "github.com,api.github.com", + WorkflowData: &WorkflowData{ + EngineConfig: &EngineConfig{ID: "copilot"}, + NetworkPermissions: &NetworkPermissions{ + Firewall: &FirewallConfig{Enabled: true}, + }, + SandboxConfig: &SandboxConfig{ + Agent: &AgentSandboxConfig{ + ID: "awf", + BoundedQueries: bq, + }, + }, + }, + } + } + + t.Run("omits boundedQueries when not configured", func(t *testing.T) { + config := makeBaseConfig(nil) + jsonStr, err := BuildAWFConfigJSON(config) + require.NoError(t, err) + assert.NotContains(t, jsonStr, `"boundedQueries"`, "boundedQueries section must be absent when not configured") + }) + + t.Run("emits boundedQueries with enabled:true and private repos", func(t *testing.T) { + bq := &BoundedQueriesConfig{ + PrivateRepos: []*BoundedQueryPrivateRepo{ + {Repo: "my-org/public-docs", Sensitivity: "public"}, + {Repo: "my-org/internal-service", Sensitivity: "internal"}, + {Repo: "my-org/confidential-service", Sensitivity: "confidential"}, + {Repo: "my-org/sealed-service", Sensitivity: "sealed"}, + }, + } + // Use a version that supports bounded queries. + config := makeBaseConfig(bq) + config.WorkflowData.SandboxConfig.Agent.Version = string(constants.AWFBoundedQueriesMinVersion) + + jsonStr, err := BuildAWFConfigJSON(config) + require.NoError(t, err) + + var parsed map[string]any + require.NoError(t, json.Unmarshal([]byte(jsonStr), &parsed)) + + bqSection, ok := parsed["boundedQueries"].(map[string]any) + require.True(t, ok, "boundedQueries section must be present") + assert.Equal(t, true, bqSection["enabled"]) + + repos, ok := bqSection["privateRepos"].([]any) + require.True(t, ok, "privateRepos must be an array") + require.Len(t, repos, 4) + + first, ok := repos[0].(map[string]any) + require.True(t, ok) + assert.Equal(t, "my-org/public-docs", first["repo"]) + assert.Equal(t, "public", first["sensitivity"]) + }) + + t.Run("emits optional fields when set", func(t *testing.T) { + bq := &BoundedQueriesConfig{ + PrivateRepos: []*BoundedQueryPrivateRepo{ + {Repo: "my-org/internal-service", Sensitivity: "internal"}, + }, + Runtime: "docker", + Timeout: 30, + MemoryLimit: "512m", + Interpreter: "python3", + MaxInvocations: 32, + } + config := makeBaseConfig(bq) + config.WorkflowData.SandboxConfig.Agent.Version = string(constants.AWFBoundedQueriesMinVersion) + + jsonStr, err := BuildAWFConfigJSON(config) + require.NoError(t, err) + + assert.Contains(t, jsonStr, `"runtime":"docker"`) + assert.Contains(t, jsonStr, `"timeout":30`) + assert.Contains(t, jsonStr, `"memoryLimit":"512m"`) + assert.Contains(t, jsonStr, `"interpreter":"python3"`) + assert.Contains(t, jsonStr, `"maxInvocations":32`) + }) + + t.Run("omits optional fields when unset (AWF stays source of truth for defaults)", func(t *testing.T) { + bq := &BoundedQueriesConfig{ + PrivateRepos: []*BoundedQueryPrivateRepo{ + {Repo: "my-org/internal-service", Sensitivity: "internal"}, + }, + // No optional fields. + } + config := makeBaseConfig(bq) + config.WorkflowData.SandboxConfig.Agent.Version = string(constants.AWFBoundedQueriesMinVersion) + + jsonStr, err := BuildAWFConfigJSON(config) + require.NoError(t, err) + + assert.NotContains(t, jsonStr, `"runtime"`, "runtime must be omitted when unset") + assert.NotContains(t, jsonStr, `"timeout"`, "timeout must be omitted when unset") + assert.NotContains(t, jsonStr, `"memoryLimit"`, "memoryLimit must be omitted when unset") + assert.NotContains(t, jsonStr, `"interpreter"`, "interpreter must be omitted when unset") + assert.NotContains(t, jsonStr, `"maxInvocations"`, "maxInvocations must be omitted when unset") + }) + + t.Run("skips boundedQueries section for unsupported AWF versions", func(t *testing.T) { + bq := &BoundedQueriesConfig{ + PrivateRepos: []*BoundedQueryPrivateRepo{ + {Repo: "my-org/internal-service", Sensitivity: "internal"}, + }, + } + config := makeBaseConfig(bq) + // Pin to a version that predates bounded queries support. + config.WorkflowData.SandboxConfig.Agent.Version = "v0.27.42" + + jsonStr, err := BuildAWFConfigJSON(config) + require.NoError(t, err) + assert.NotContains(t, jsonStr, `"boundedQueries"`, "boundedQueries must be skipped for unsupported AWF versions") + }) + + t.Run("nil sandbox config does not emit boundedQueries", func(t *testing.T) { + config := AWFCommandConfig{ + EngineName: "copilot", + AllowedDomains: "github.com", + WorkflowData: &WorkflowData{ + EngineConfig: &EngineConfig{ID: "copilot"}, + NetworkPermissions: &NetworkPermissions{ + Firewall: &FirewallConfig{Enabled: true}, + }, + }, + } + jsonStr, err := BuildAWFConfigJSON(config) + require.NoError(t, err) + assert.NotContains(t, jsonStr, `"boundedQueries"`) + }) +} + +// TestExtractBoundedQueriesConfig validates the extraction helper in isolation. +func TestExtractBoundedQueriesConfig(t *testing.T) { + t.Run("returns nil for nil WorkflowData", func(t *testing.T) { + assert.Nil(t, extractBoundedQueriesConfig(nil)) + }) + + t.Run("returns nil for missing sandbox config", func(t *testing.T) { + assert.Nil(t, extractBoundedQueriesConfig(&WorkflowData{})) + }) + + t.Run("returns nil for missing agent config", func(t *testing.T) { + assert.Nil(t, extractBoundedQueriesConfig(&WorkflowData{ + SandboxConfig: &SandboxConfig{}, + })) + }) + + t.Run("returns nil when bounded-queries is absent", func(t *testing.T) { + assert.Nil(t, extractBoundedQueriesConfig(&WorkflowData{ + SandboxConfig: &SandboxConfig{ + Agent: &AgentSandboxConfig{ID: "awf"}, + }, + })) + }) + + t.Run("maps all fields correctly", func(t *testing.T) { + data := &WorkflowData{ + SandboxConfig: &SandboxConfig{ + Agent: &AgentSandboxConfig{ + ID: "awf", + BoundedQueries: &BoundedQueriesConfig{ + PrivateRepos: []*BoundedQueryPrivateRepo{ + {Repo: "my-org/internal-service", Sensitivity: "internal"}, + {Repo: "my-org/confidential-service", Sensitivity: "confidential"}, + }, + Runtime: "docker", + Timeout: 30, + MemoryLimit: "512m", + Interpreter: "python3", + MaxInvocations: 32, + }, + }, + }, + } + + got := extractBoundedQueriesConfig(data) + require.NotNil(t, got) + assert.True(t, got.Enabled) + assert.Equal(t, "docker", got.Runtime) + assert.Equal(t, 30, got.Timeout) + assert.Equal(t, "512m", got.MemoryLimit) + assert.Equal(t, "python3", got.Interpreter) + assert.Equal(t, 32, got.MaxInvocations) + require.Len(t, got.PrivateRepos, 2) + assert.Equal(t, "my-org/internal-service", got.PrivateRepos[0].Repo) + assert.Equal(t, "internal", got.PrivateRepos[0].Sensitivity) + assert.Equal(t, "my-org/confidential-service", got.PrivateRepos[1].Repo) + assert.Equal(t, "confidential", got.PrivateRepos[1].Sensitivity) + }) +} + +// TestValidateBoundedQueriesConfig validates all validation rules for bounded queries. +func TestValidateBoundedQueriesConfig(t *testing.T) { + validAWFAgent := func(bq *BoundedQueriesConfig) *AgentSandboxConfig { + return &AgentSandboxConfig{ID: "awf", BoundedQueries: bq} + } + + t.Run("valid minimal config passes", func(t *testing.T) { + agent := validAWFAgent(&BoundedQueriesConfig{ + PrivateRepos: []*BoundedQueryPrivateRepo{ + {Repo: "my-org/my-repo", Sensitivity: "internal"}, + }, + }) + assert.NoError(t, validateBoundedQueriesConfig(agent)) + }) + + t.Run("valid config with all optional fields passes", func(t *testing.T) { + agent := validAWFAgent(&BoundedQueriesConfig{ + PrivateRepos: []*BoundedQueryPrivateRepo{ + {Repo: "my-org/my-repo", Sensitivity: "confidential"}, + }, + Runtime: "docker", + Timeout: 30, + MemoryLimit: "512m", + Interpreter: "python3", + MaxInvocations: 32, + }) + assert.NoError(t, validateBoundedQueriesConfig(agent)) + }) + + t.Run("all four sensitivity values are accepted", func(t *testing.T) { + for _, sensitivity := range []string{"public", "internal", "confidential", "sealed"} { + t.Run(sensitivity, func(t *testing.T) { + agent := validAWFAgent(&BoundedQueriesConfig{ + PrivateRepos: []*BoundedQueryPrivateRepo{ + {Repo: "my-org/my-repo", Sensitivity: sensitivity}, + }, + }) + assert.NoError(t, validateBoundedQueriesConfig(agent)) + }) + } + }) + + t.Run("nil agent or nil bounded-queries returns nil", func(t *testing.T) { + assert.NoError(t, validateBoundedQueriesConfig(nil)) + assert.NoError(t, validateBoundedQueriesConfig(&AgentSandboxConfig{ID: "awf"})) + }) + + t.Run("rejects non-AWF sandbox", func(t *testing.T) { + agent := &AgentSandboxConfig{ + // No ID / type set — will be empty string. + BoundedQueries: &BoundedQueriesConfig{ + PrivateRepos: []*BoundedQueryPrivateRepo{ + {Repo: "my-org/my-repo", Sensitivity: "internal"}, + }, + }, + } + err := validateBoundedQueriesConfig(agent) + require.Error(t, err) + assert.Contains(t, err.Error(), "bounded-queries requires the AWF sandbox") + }) + + t.Run("rejects empty private-repos", func(t *testing.T) { + agent := validAWFAgent(&BoundedQueriesConfig{ + PrivateRepos: []*BoundedQueryPrivateRepo{}, + }) + err := validateBoundedQueriesConfig(agent) + require.Error(t, err) + assert.Contains(t, err.Error(), "at least one private-repos entry") + }) + + t.Run("rejects nil private-repos", func(t *testing.T) { + agent := validAWFAgent(&BoundedQueriesConfig{}) + err := validateBoundedQueriesConfig(agent) + require.Error(t, err) + assert.Contains(t, err.Error(), "at least one private-repos entry") + }) + + t.Run("rejects invalid sensitivity value", func(t *testing.T) { + agent := validAWFAgent(&BoundedQueriesConfig{ + PrivateRepos: []*BoundedQueryPrivateRepo{ + {Repo: "my-org/my-repo", Sensitivity: "top-secret"}, + }, + }) + err := validateBoundedQueriesConfig(agent) + require.Error(t, err) + assert.Contains(t, err.Error(), "sensitivity must be one of") + }) + + t.Run("rejects duplicate repo slugs", func(t *testing.T) { + agent := validAWFAgent(&BoundedQueriesConfig{ + PrivateRepos: []*BoundedQueryPrivateRepo{ + {Repo: "my-org/my-repo", Sensitivity: "internal"}, + {Repo: "my-org/my-repo", Sensitivity: "confidential"}, + }, + }) + err := validateBoundedQueriesConfig(agent) + require.Error(t, err) + assert.Contains(t, err.Error(), "duplicate repository slug") + }) + + t.Run("rejects GitHub Actions expressions in repo slug", func(t *testing.T) { + agent := validAWFAgent(&BoundedQueriesConfig{ + PrivateRepos: []*BoundedQueryPrivateRepo{ + {Repo: "${{ inputs.repo }}", Sensitivity: "internal"}, + }, + }) + err := validateBoundedQueriesConfig(agent) + require.Error(t, err) + assert.Contains(t, err.Error(), "must not contain GitHub Actions expressions") + }) + + t.Run("rejects malformed repo slug (missing slash)", func(t *testing.T) { + agent := validAWFAgent(&BoundedQueriesConfig{ + PrivateRepos: []*BoundedQueryPrivateRepo{ + {Repo: "myrepo", Sensitivity: "internal"}, + }, + }) + err := validateBoundedQueriesConfig(agent) + require.Error(t, err) + assert.Contains(t, err.Error(), "'owner/repo' format") + }) + + t.Run("rejects empty repo slug", func(t *testing.T) { + agent := validAWFAgent(&BoundedQueriesConfig{ + PrivateRepos: []*BoundedQueryPrivateRepo{ + {Repo: "", Sensitivity: "internal"}, + }, + }) + err := validateBoundedQueriesConfig(agent) + require.Error(t, err) + assert.Contains(t, err.Error(), "must not be empty") + }) + + t.Run("rejects unsupported runtime", func(t *testing.T) { + agent := validAWFAgent(&BoundedQueriesConfig{ + PrivateRepos: []*BoundedQueryPrivateRepo{ + {Repo: "my-org/my-repo", Sensitivity: "internal"}, + }, + Runtime: "podman", + }) + err := validateBoundedQueriesConfig(agent) + require.Error(t, err) + assert.Contains(t, err.Error(), "unsupported bounded-queries runtime") + }) + + t.Run("rejects negative timeout", func(t *testing.T) { + agent := validAWFAgent(&BoundedQueriesConfig{ + PrivateRepos: []*BoundedQueryPrivateRepo{ + {Repo: "my-org/my-repo", Sensitivity: "internal"}, + }, + Timeout: -1, + }) + err := validateBoundedQueriesConfig(agent) + require.Error(t, err) + assert.Contains(t, err.Error(), "timeout must be a positive integer") + }) + + t.Run("rejects invalid memory-limit format", func(t *testing.T) { + for _, invalid := range []string{"512", "512mb", "5.5g", "abc"} { + t.Run(invalid, func(t *testing.T) { + agent := validAWFAgent(&BoundedQueriesConfig{ + PrivateRepos: []*BoundedQueryPrivateRepo{ + {Repo: "my-org/my-repo", Sensitivity: "internal"}, + }, + MemoryLimit: invalid, + }) + err := validateBoundedQueriesConfig(agent) + require.Error(t, err) + assert.Contains(t, err.Error(), "memory-limit") + }) + } + }) + + t.Run("accepts valid memory-limit formats", func(t *testing.T) { + for _, valid := range []string{"512m", "2g", "1024k", "512M", "2G"} { + t.Run(valid, func(t *testing.T) { + agent := validAWFAgent(&BoundedQueriesConfig{ + PrivateRepos: []*BoundedQueryPrivateRepo{ + {Repo: "my-org/my-repo", Sensitivity: "internal"}, + }, + MemoryLimit: valid, + }) + assert.NoError(t, validateBoundedQueriesConfig(agent)) + }) + } + }) + + t.Run("rejects unsupported interpreter", func(t *testing.T) { + agent := validAWFAgent(&BoundedQueriesConfig{ + PrivateRepos: []*BoundedQueryPrivateRepo{ + {Repo: "my-org/my-repo", Sensitivity: "internal"}, + }, + Interpreter: "ruby", + }) + err := validateBoundedQueriesConfig(agent) + require.Error(t, err) + assert.Contains(t, err.Error(), "unsupported bounded-queries interpreter") + }) + + t.Run("rejects negative max-invocations", func(t *testing.T) { + agent := validAWFAgent(&BoundedQueriesConfig{ + PrivateRepos: []*BoundedQueryPrivateRepo{ + {Repo: "my-org/my-repo", Sensitivity: "internal"}, + }, + MaxInvocations: -1, + }) + err := validateBoundedQueriesConfig(agent) + require.Error(t, err) + assert.Contains(t, err.Error(), "max-invocations must be a positive integer") + }) +} + +// TestValidateRepoSlug covers edge cases for the repo-slug validator. +func TestValidateRepoSlug(t *testing.T) { + valid := []string{ + "my-org/my-repo", + "github/gh-aw", + "my_org/my_repo", + "my-org/my.repo", + "a/b", + } + for _, slug := range valid { + t.Run("valid: "+slug, func(t *testing.T) { + assert.NoError(t, validateRepoSlug("field", slug)) + }) + } + + invalid := []string{ + "", + "myrepo", + "/myrepo", + "my-org/", + "${{ inputs.owner }}/my-repo", + "my-org/${{ inputs.repo }}", + } + for _, slug := range invalid { + t.Run("invalid: "+slug, func(t *testing.T) { + assert.Error(t, validateRepoSlug("field", slug)) + }) + } +} + +// TestAWFBoundedQueriesJSONRoundtrip verifies the JSON serialization of AWFBoundedQueriesConfig. +func TestAWFBoundedQueriesJSONRoundtrip(t *testing.T) { + cfg := &AWFBoundedQueriesConfig{ + Enabled: true, + PrivateRepos: []*AWFBoundedQueryPrivateRepo{ + {Repo: "my-org/public-docs", Sensitivity: "public"}, + {Repo: "my-org/internal-service", Sensitivity: "internal"}, + {Repo: "my-org/confidential-service", Sensitivity: "confidential"}, + {Repo: "my-org/sealed-service", Sensitivity: "sealed"}, + }, + Runtime: "docker", + Timeout: 30, + MemoryLimit: "512m", + Interpreter: "python3", + MaxInvocations: 32, + } + + data, err := json.Marshal(cfg) + require.NoError(t, err) + + jsonStr := string(data) + assert.Contains(t, jsonStr, `"enabled":true`) + assert.Contains(t, jsonStr, `"privateRepos"`) + assert.Contains(t, jsonStr, `"my-org/public-docs"`) + assert.Contains(t, jsonStr, `"sealed"`) + assert.Contains(t, jsonStr, `"runtime":"docker"`) + assert.Contains(t, jsonStr, `"timeout":30`) + assert.Contains(t, jsonStr, `"memoryLimit":"512m"`) + assert.Contains(t, jsonStr, `"interpreter":"python3"`) + assert.Contains(t, jsonStr, `"maxInvocations":32`) + + // Round-trip through JSON. + var got AWFBoundedQueriesConfig + require.NoError(t, json.Unmarshal(data, &got)) + assert.True(t, got.Enabled) + require.Len(t, got.PrivateRepos, 4) + assert.Equal(t, "my-org/public-docs", got.PrivateRepos[0].Repo) + assert.Equal(t, "public", got.PrivateRepos[0].Sensitivity) + assert.Equal(t, "my-org/sealed-service", got.PrivateRepos[3].Repo) + assert.Equal(t, "sealed", got.PrivateRepos[3].Sensitivity) +} diff --git a/pkg/workflow/sandbox.go b/pkg/workflow/sandbox.go index b1c282f07b2..57fc051e798 100644 --- a/pkg/workflow/sandbox.go +++ b/pkg/workflow/sandbox.go @@ -62,24 +62,84 @@ const ( // AgentSandboxConfig represents the agent sandbox configuration type AgentSandboxConfig struct { - ID string `yaml:"id,omitempty"` // Agent ID: "awf" or "srt" (replaces Type in new object format) - Type SandboxType `yaml:"type,omitempty"` // Sandbox type: "awf" or "srt" (legacy, use ID instead) - Version string `yaml:"version,omitempty"` // AWF version override used to install and run the matching firewall version - Platform string `yaml:"platform,omitempty"` // AWF platform.type override (github.com, ghes, ghec, ghec-self-hosted) - Runtime AgentRuntime `yaml:"runtime,omitempty"` // Container runtime for the agent container (e.g., "gvisor") - NetworkIsolation bool `yaml:"sudo,omitempty"` // Internal: true = isolation mode (AWF --network-isolation). Frontmatter sudo: false (or omitted) maps to NetworkIsolation=true; sudo: true maps to NetworkIsolation=false. - SudoExplicitlyEnabled bool `yaml:"-"` // True when sudo: true was explicitly set in frontmatter. Used to emit an error (strict) or warning (non-strict) at compile time. - LegacySecurity bool `yaml:"-"` // True when legacy-security: enable was set in frontmatter. Enables sudo, host-access, and iptables-based mode. - Disabled bool `yaml:"-"` // True when agent is explicitly set to false (disables firewall). This is a runtime flag, not serialized to YAML. - DisableReason string `yaml:"-"` // Operator-authored justification from dangerously-disable-sandbox-agent feature; available for diagnostics and audit logging. - Config *SandboxRuntimeConfig `yaml:"config,omitempty"` // Custom SRT config (optional) - Command string `yaml:"command,omitempty"` // Custom command to replace AWF or SRT installation - Args []string `yaml:"args,omitempty"` // Additional arguments to append to the command - Env map[string]string `yaml:"env,omitempty"` // Environment variables to set on the step - Mounts []string `yaml:"mounts,omitempty"` // Container mounts to add for AWF (format: "source:dest:mode") - Memory string `yaml:"memory,omitempty"` // Memory limit for the AWF container (e.g., "4g", "8g") - ModelFallback *TemplatableBool `yaml:"model-fallback,omitempty"` // AWF API proxy model fallback enable/disable flag (optional) - Targets map[string]*AgentAPIProxyTargetConfig `yaml:"targets,omitempty"` // Per-provider API proxy target overrides keyed by provider name (e.g. "openai", "anthropic") + ID string `yaml:"id,omitempty"` // Agent ID: "awf" or "srt" (replaces Type in new object format) + Type SandboxType `yaml:"type,omitempty"` // Sandbox type: "awf" or "srt" (legacy, use ID instead) + Version string `yaml:"version,omitempty"` // AWF version override used to install and run the matching firewall version + Platform string `yaml:"platform,omitempty"` // AWF platform.type override (github.com, ghes, ghec, ghec-self-hosted) + Runtime AgentRuntime `yaml:"runtime,omitempty"` // Container runtime for the agent container (e.g., "gvisor") + NetworkIsolation bool `yaml:"sudo,omitempty"` // Internal: true = isolation mode (AWF --network-isolation). Frontmatter sudo: false (or omitted) maps to NetworkIsolation=true; sudo: true maps to NetworkIsolation=false. + SudoExplicitlyEnabled bool `yaml:"-"` // True when sudo: true was explicitly set in frontmatter. Used to emit an error (strict) or warning (non-strict) at compile time. + LegacySecurity bool `yaml:"-"` // True when legacy-security: enable was set in frontmatter. Enables sudo, host-access, and iptables-based mode. + Disabled bool `yaml:"-"` // True when agent is explicitly set to false (disables firewall). This is a runtime flag, not serialized to YAML. + DisableReason string `yaml:"-"` // Operator-authored justification from dangerously-disable-sandbox-agent feature; available for diagnostics and audit logging. + Config *SandboxRuntimeConfig `yaml:"config,omitempty"` // Custom SRT config (optional) + Command string `yaml:"command,omitempty"` // Custom command to replace AWF or SRT installation + Args []string `yaml:"args,omitempty"` // Additional arguments to append to the command + Env map[string]string `yaml:"env,omitempty"` // Environment variables to set on the step + Mounts []string `yaml:"mounts,omitempty"` // Container mounts to add for AWF (format: "source:dest:mode") + Memory string `yaml:"memory,omitempty"` // Memory limit for the AWF container (e.g., "4g", "8g") + ModelFallback *TemplatableBool `yaml:"model-fallback,omitempty"` // AWF API proxy model fallback enable/disable flag (optional) + Targets map[string]*AgentAPIProxyTargetConfig `yaml:"targets,omitempty"` // Per-provider API proxy target overrides keyed by provider name (e.g. "openai", "anthropic") + BoundedQueries *BoundedQueriesConfig `yaml:"bounded-queries,omitempty"` // Bounded-query configuration for cross-repository private data access +} + +// BoundedQueriesConfig configures the AWF bounded-query subsystem, which allows the agent +// to answer finite, pre-approved questions about private repositories without receiving +// raw source content. The presence of this block enables the feature. +// +// Example frontmatter: +// +// sandbox: +// agent: +// id: awf +// bounded-queries: +// private-repos: +// - repo: my-org/internal-service +// sensitivity: internal +// runtime: docker +// timeout: 30 +// memory-limit: 512m +// interpreter: python3 +// max-invocations: 32 +type BoundedQueriesConfig struct { + // PrivateRepos is the list of private repositories that the agent may query. + // At least one entry is required when bounded-queries is configured. + // Each entry must have a valid "owner/repo" slug and a sensitivity classification. + PrivateRepos []*BoundedQueryPrivateRepo `yaml:"private-repos,omitempty"` + + // Runtime is the container runtime used to execute bounded-query scripts. + // Optional; when omitted AWF uses its default runtime. + // Supported values: "docker" + Runtime string `yaml:"runtime,omitempty"` + + // Timeout is the maximum execution time in seconds for a single bounded-query invocation. + // Optional; when omitted AWF uses its default timeout. + // Must be a positive integer. + Timeout int `yaml:"timeout,omitempty"` + + // MemoryLimit is the memory limit for bounded-query container execution (e.g. "512m", "1g"). + // Optional; when omitted AWF uses its default memory limit. + MemoryLimit string `yaml:"memory-limit,omitempty"` + + // Interpreter is the script interpreter for bounded-query execution (e.g. "python3"). + // Optional; when omitted AWF uses its default interpreter. + Interpreter string `yaml:"interpreter,omitempty"` + + // MaxInvocations is the maximum number of bounded-query invocations allowed per run. + // Optional; when omitted AWF uses its default. + // Must be a positive integer. + MaxInvocations int `yaml:"max-invocations,omitempty"` +} + +// BoundedQueryPrivateRepo describes one private repository approved for bounded-query access. +type BoundedQueryPrivateRepo struct { + // Repo is the "owner/repo" slug of the private repository. + // Must not contain GitHub Actions expressions. + Repo string `yaml:"repo"` + + // Sensitivity is the confidentiality classification for this repository. + // Accepted values: "public", "internal", "confidential", "sealed". + Sensitivity string `yaml:"sensitivity"` } // AiCreditsPricingConfig holds per-token pricing rates ($/1M tokens) used as a fallback diff --git a/pkg/workflow/sandbox_validation.go b/pkg/workflow/sandbox_validation.go index 804590a2fa5..e79399c9493 100644 --- a/pkg/workflow/sandbox_validation.go +++ b/pkg/workflow/sandbox_validation.go @@ -3,6 +3,7 @@ // This file contains domain-specific validation functions for sandbox configuration: // - validateMountsSyntax() - Validates container mount syntax // - validateSandboxConfig() - Validates complete sandbox configuration +// - validateBoundedQueriesConfig() - Validates bounded-query configuration // // These validation functions are organized in a dedicated file following the validation // architecture pattern where domain-specific validation belongs in domain validation files. @@ -14,6 +15,7 @@ import ( "errors" "fmt" "regexp" + "strconv" "strings" "github.com/github/gh-aw/pkg/constants" @@ -208,6 +210,200 @@ func validateSandboxConfig(workflowData *WorkflowData) error { sandboxValidationLog.Print("Agent sandbox enabled with MCP gateway - validation passed") } + // Validate bounded-queries configuration when present. + if sandboxConfig.Agent != nil && sandboxConfig.Agent.BoundedQueries != nil { + if err := validateBoundedQueriesConfig(sandboxConfig.Agent); err != nil { + return err + } + } + + return nil +} + +// validBoundedQuerySensitivities is the set of accepted sensitivity classifications. +var validBoundedQuerySensitivities = map[string]struct{}{ + "public": {}, + "internal": {}, + "confidential": {}, + "sealed": {}, +} + +// validBoundedQueryRuntimes is the set of accepted container runtimes. +var validBoundedQueryRuntimes = map[string]struct{}{ + "docker": {}, +} + +// validBoundedQueryInterpreters is the set of accepted script interpreters. +var validBoundedQueryInterpreters = map[string]struct{}{ + "python3": {}, +} + +// validateBoundedQueriesConfig validates sandbox.agent.bounded-queries configuration. +// Returns an error when the configuration is invalid. +func validateBoundedQueriesConfig(agentConfig *AgentSandboxConfig) error { + if agentConfig == nil || agentConfig.BoundedQueries == nil { + return nil + } + + // bounded-queries is only supported for the AWF sandbox. + agentType := getAgentType(agentConfig) + if !isSupportedSandboxType(agentType) { + return NewValidationError( + "sandbox.agent.bounded-queries", + string(agentType), + "bounded-queries requires the AWF sandbox (sandbox.agent.id: awf)", + "Set sandbox.agent.id: awf when using bounded-queries:\n\nsandbox:\n agent:\n id: awf\n bounded-queries:\n private-repos:\n - repo: my-org/my-repo\n sensitivity: internal\n\nSee: "+string(constants.DocsSandboxURL), + ) + } + + bq := agentConfig.BoundedQueries + + // Validate that private-repos is non-empty. + if len(bq.PrivateRepos) == 0 { + return NewValidationError( + "sandbox.agent.bounded-queries.private-repos", + "[]", + "bounded-queries requires at least one private-repos entry", + "Add at least one repository to private-repos:\n\nsandbox:\n agent:\n bounded-queries:\n private-repos:\n - repo: my-org/my-repo\n sensitivity: internal\n\nSee: "+string(constants.DocsSandboxURL), + ) + } + + // Validate each private-repo entry. + seen := make(map[string]struct{}, len(bq.PrivateRepos)) + for i, r := range bq.PrivateRepos { + field := fmt.Sprintf("sandbox.agent.bounded-queries.private-repos[%d]", i) + + if r == nil { + return NewValidationError(field, "", "private-repos entry must not be null", "") + } + + // Validate repo slug format. + if err := validateRepoSlug(field+".repo", r.Repo); err != nil { + return err + } + + // Validate sensitivity. + if _, ok := validBoundedQuerySensitivities[r.Sensitivity]; !ok { + const validValues = "public, internal, confidential, sealed" + return NewValidationError( + field+".sensitivity", + r.Sensitivity, + "sensitivity must be one of: "+validValues, + "Use one of the accepted sensitivity values:\n\nsandbox:\n agent:\n bounded-queries:\n private-repos:\n - repo: my-org/my-repo\n sensitivity: internal # one of: "+validValues+"\n\nSee: "+string(constants.DocsSandboxURL), + ) + } + + // Validate no duplicates. + key := r.Repo + if _, dup := seen[key]; dup { + return NewValidationError( + field+".repo", + r.Repo, + "duplicate repository slug in bounded-queries.private-repos", + fmt.Sprintf("Each repository may appear at most once in bounded-queries.private-repos. Remove the duplicate entry for %q.\n\nSee: %s", r.Repo, constants.DocsSandboxURL), + ) + } + seen[key] = struct{}{} + } + + // Validate optional runtime. + if bq.Runtime != "" { + if _, ok := validBoundedQueryRuntimes[bq.Runtime]; !ok { + return NewValidationError( + "sandbox.agent.bounded-queries.runtime", + bq.Runtime, + "unsupported bounded-queries runtime: must be \"docker\"", + fmt.Sprintf("Set runtime to a supported value:\n\nsandbox:\n agent:\n bounded-queries:\n runtime: docker\n\nSee: %s", constants.DocsSandboxURL), + ) + } + } + + // Validate optional timeout. + if bq.Timeout < 0 { + return NewValidationError( + "sandbox.agent.bounded-queries.timeout", + strconv.Itoa(bq.Timeout), + "bounded-queries timeout must be a positive integer", + fmt.Sprintf("Set timeout to a positive number of seconds.\n\nSee: %s", constants.DocsSandboxURL), + ) + } + + // Validate optional memory-limit format (e.g. "512m", "2g"). + if bq.MemoryLimit != "" { + if err := validateBoundedQueryMemoryLimit(bq.MemoryLimit); err != nil { + return err + } + } + + // Validate optional interpreter. + if bq.Interpreter != "" { + if _, ok := validBoundedQueryInterpreters[bq.Interpreter]; !ok { + return NewValidationError( + "sandbox.agent.bounded-queries.interpreter", + bq.Interpreter, + "unsupported bounded-queries interpreter: must be \"python3\"", + fmt.Sprintf("Set interpreter to a supported value:\n\nsandbox:\n agent:\n bounded-queries:\n interpreter: python3\n\nSee: %s", constants.DocsSandboxURL), + ) + } + } + + // Validate optional max-invocations. + if bq.MaxInvocations < 0 { + return NewValidationError( + "sandbox.agent.bounded-queries.max-invocations", + strconv.Itoa(bq.MaxInvocations), + "bounded-queries max-invocations must be a positive integer", + fmt.Sprintf("Set max-invocations to a positive integer.\n\nSee: %s", constants.DocsSandboxURL), + ) + } + + sandboxValidationLog.Printf("bounded-queries validation passed: %d private repo(s)", len(bq.PrivateRepos)) + return nil +} + +// validateRepoSlug validates a repository slug in "owner/repo" format. +// Returns a validation error for empty values, GitHub Actions expressions, or malformed slugs. +func validateRepoSlug(field, slug string) error { + if slug == "" { + return NewValidationError( + field, + "", + "repository slug must not be empty", + fmt.Sprintf("Provide a valid 'owner/repo' slug.\n\nSee: %s", constants.DocsSandboxURL), + ) + } + if githubActionsExpressionPattern.MatchString(slug) { + return NewValidationError( + field, + slug, + "repository slug must not contain GitHub Actions expressions", + fmt.Sprintf("Use a literal 'owner/repo' slug; dynamic values are not permitted in bounded-queries.\n\nSee: %s", constants.DocsSandboxURL), + ) + } + if !repoSlugPattern.MatchString(slug) { + return NewValidationError( + field, + slug, + "repository slug must be in 'owner/repo' format", + fmt.Sprintf("Use a valid 'owner/repo' slug (owner: alphanumeric characters, hyphens, underscores; repo: alphanumeric characters, hyphens, underscores, and dots).\n\nSee: %s", constants.DocsSandboxURL), + ) + } + return nil +} + +// memoryLimitPattern matches valid memory limit strings (e.g. "512m", "2g", "1024k"). +var memoryLimitPattern = regexp.MustCompile(`^\d+[kmgKMG]$`) + +// validateBoundedQueryMemoryLimit checks that a memory-limit string has the correct format. +func validateBoundedQueryMemoryLimit(memoryLimit string) error { + if !memoryLimitPattern.MatchString(memoryLimit) { + return NewValidationError( + "sandbox.agent.bounded-queries.memory-limit", + memoryLimit, + "memory-limit must be a number followed by a unit: k, m, or g (e.g. \"512m\", \"2g\")", + fmt.Sprintf("Use a valid memory limit format:\n\nsandbox:\n agent:\n bounded-queries:\n memory-limit: 512m # examples: 512m, 2g, 1024k\n\nSee: %s", constants.DocsSandboxURL), + ) + } return nil } diff --git a/pkg/workflow/schemas/awf-config.schema.json b/pkg/workflow/schemas/awf-config.schema.json index df1258f28cf..0fe063e0972 100644 --- a/pkg/workflow/schemas/awf-config.schema.json +++ b/pkg/workflow/schemas/awf-config.schema.json @@ -766,6 +766,65 @@ "description": "Container image providing system-level build tools (gcc, make, libraries) for the agent's chroot base. Used as an init container that copies its filesystem into a named volume mounted at /host. Only used when runner.topology is 'arc-dind'. Defaults to 'ghcr.io/github/gh-aw-firewall/build-tools:'." } } + }, + "boundedQueries": { + "type": "object", + "description": "Bounded-query subsystem configuration. When present, enables the agent to answer finite, pre-approved questions about private repositories without receiving raw source content. The staging credential used to access private repositories must remain host-side and must not be written to the generated config or reach the agent environment.", + "required": ["enabled"], + "additionalProperties": false, + "properties": { + "enabled": { + "type": "boolean", + "description": "Must be true to activate the bounded-query subsystem. Set automatically by gh-aw when sandbox.agent.bounded-queries is configured." + }, + "privateRepos": { + "type": "array", + "description": "List of private repositories approved for bounded-query access.", + "minItems": 1, + "items": { + "type": "object", + "required": ["repo", "sensitivity"], + "additionalProperties": false, + "properties": { + "repo": { + "type": "string", + "description": "The 'owner/repo' slug of the approved private repository. Must be a literal value; GitHub Actions expressions are not permitted.", + "pattern": "^[a-zA-Z0-9][a-zA-Z0-9._-]*/[a-zA-Z0-9][a-zA-Z0-9._-]*$" + }, + "sensitivity": { + "type": "string", + "description": "Confidentiality classification for this repository.", + "enum": ["public", "internal", "confidential", "sealed"] + } + } + } + }, + "runtime": { + "type": "string", + "description": "Container runtime used to execute bounded-query scripts. When omitted AWF uses its default.", + "enum": ["docker"] + }, + "timeout": { + "type": "integer", + "description": "Maximum execution time in seconds for a single bounded-query invocation. When omitted AWF uses its default.", + "minimum": 1 + }, + "memoryLimit": { + "type": "string", + "description": "Memory limit for bounded-query container execution (e.g. \"512m\", \"2g\"). When omitted AWF uses its default.", + "pattern": "^\\d+[kmgKMG]$" + }, + "interpreter": { + "type": "string", + "description": "Script interpreter for bounded-query execution (e.g. \"python3\"). When omitted AWF uses its default.", + "enum": ["python3"] + }, + "maxInvocations": { + "type": "integer", + "description": "Maximum number of bounded-query invocations allowed per run. When omitted AWF uses its default.", + "minimum": 1 + } + } } }, "$defs": { From 8e8f2f2ef4a7ee2d3ed3b970edf3aa032bd7c4a4 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 30 Jul 2026 15:39:12 +0000 Subject: [PATCH 3/5] feat: move bounded-queries config from sandbox.agent to tools.github Co-authored-by: lpcox <15877973+lpcox@users.noreply.github.com> --- .github/aw/syntax-agentic.md | 16 ++-- pkg/workflow/awf_config.go | 6 +- pkg/workflow/bounded_queries_test.go | 125 ++++++++++++++++----------- pkg/workflow/compiler_validators.go | 1 + pkg/workflow/sandbox.go | 96 ++++---------------- pkg/workflow/sandbox_validation.go | 54 ++++++------ pkg/workflow/tools_parser.go | 46 +++++++++- pkg/workflow/tools_types.go | 64 ++++++++++++++ 8 files changed, 240 insertions(+), 168 deletions(-) diff --git a/.github/aw/syntax-agentic.md b/.github/aw/syntax-agentic.md index 67971a41b0e..5ad26601731 100644 --- a/.github/aw/syntax-agentic.md +++ b/.github/aw/syntax-agentic.md @@ -314,12 +314,14 @@ description: Agentic workflow specific frontmatter fields for GitHub Agentic Wor - **`sandbox.agent.sudo`** (boolean) controls whether AWF runs in root mode. Default is `false`: AWF runs rootless in network-isolation egress mode (`--network-isolation`), with MCP sidecars attached as bridge containers on the internal `awf-net` network. Set `sudo: true` for the legacy root mode; in strict mode explicit `sudo: true` is an error (warning otherwise). - **`sandbox.agent.runtime`** (string) selects an extra-isolation container runtime for the agent: `gvisor` (runs under gVisor's `runsc` for kernel-level isolation) or `docker-sbx` (Docker sbx microVM with KVM hypervisor-level isolation; needs `DOCKER_PAT`/`DOCKER_USERNAME` secrets and a KVM-capable runner). Both require `sudo: true` and are incompatible with `runner.topology: arc-dind`. - - **`sandbox.agent.bounded-queries`** (object, AWF v0.28.0+) configures the AWF bounded-query subsystem for cross-repository private data access. When present, the agent may answer finite, pre-approved questions about the listed repositories using the generated `bounded-query` skill — without receiving raw source code. This is the preferred pattern for cross-repository workflows. All optional fields use AWF defaults when omitted. + - **Strict mode**: `sandbox.agent` blocks without an explicit `id: awf` are rejected in strict mode. Any non-nil, non-disabled agent config without `id`/`type` defaults to AWF at runtime. + +- **`tools:`** - Tool configuration for the coding agent (`github`, `agentic-workflows`, `edit`, `web-fetch`, `web-search`, `bash`, `playwright`, custom MCP server names, plus `timeout`/`startup-timeout`/`cli-proxy`). See [syntax-tools-imports.md](syntax-tools-imports.md#tool-configuration) for the full schema (GitHub `mode`/`toolsets`/integrity fields, bash allowlist decision rule, Playwright CLI mode). + - **`tools.github.bounded-queries`** (object, AWF v0.28.0+) configures the AWF bounded-query subsystem for cross-repository private data access. When present, the agent may answer finite, pre-approved questions about the listed repositories using the generated `bounded-query` skill — without receiving raw source code. This is the preferred pattern for cross-repository workflows. Requires the AWF sandbox (`sandbox.agent.id: awf`). All optional fields use AWF defaults when omitted. ```yaml - sandbox: - agent: - id: awf + tools: + github: bounded-queries: private-repos: - repo: my-org/public-docs @@ -331,12 +333,12 @@ description: Agentic workflow specific frontmatter fields for GitHub Agentic Wor memory-limit: 512m # optional; e.g. 512m, 2g; default: AWF default interpreter: python3 # optional; default: AWF default max-invocations: 32 # optional; default: AWF default + sandbox: + agent: + id: awf ``` Sensitivity levels: `public` (no restrictions), `internal` (internal-only audiences), `confidential` (restricted within org), `sealed` (highest restriction). The staging credential used to access private repositories must remain host-side and is never written to the lock file or exposed to the agent. Use bounded queries when the question has a finite, bounded answer; prefer this over granting a cross-repository token or checking out the private repository into the primary workspace. - - **Strict mode**: `sandbox.agent` blocks without an explicit `id: awf` are rejected in strict mode. Any non-nil, non-disabled agent config without `id`/`type` defaults to AWF at runtime. - -- **`tools:`** - Tool configuration for the coding agent (`github`, `agentic-workflows`, `edit`, `web-fetch`, `web-search`, `bash`, `playwright`, custom MCP server names, plus `timeout`/`startup-timeout`/`cli-proxy`). See [syntax-tools-imports.md](syntax-tools-imports.md#tool-configuration) for the full schema (GitHub `mode`/`toolsets`/integrity fields, bash allowlist decision rule, Playwright CLI mode). - **`safe-outputs:`** - Safe output processing configuration. See [safe-outputs.md](safe-outputs.md) for complete documentation of all output types: `create-issue`, `create-discussion`, `add-comment`, `create-pull-request`, `push-to-pull-request-branch`, `close-issue`, `close-discussion`, `update-issue`, `update-pull-request`, `add-labels`, `remove-labels`, `replace-label`, `dispatch-workflow`, `call-workflow`, `create-code-scanning-alert`, `upload-asset`, `upload-artifact`, `assign-to-agent`, `assign-to-user`, and more. diff --git a/pkg/workflow/awf_config.go b/pkg/workflow/awf_config.go index 1bda036dd50..1ded66d874d 100644 --- a/pkg/workflow/awf_config.go +++ b/pkg/workflow/awf_config.go @@ -958,17 +958,17 @@ func extractModelCostProviders(workflowData *WorkflowData) map[string]any { } // extractBoundedQueriesConfig returns an AWFBoundedQueriesConfig populated from -// sandbox.agent.bounded-queries, or nil when the field is absent. +// tools.github.bounded-queries, or nil when the field is absent. // Only fields explicitly set in frontmatter are included; optional fields that // were not specified are omitted so that AWF remains the source of truth for defaults. func extractBoundedQueriesConfig(workflowData *WorkflowData) *AWFBoundedQueriesConfig { if workflowData == nil { return nil } - if workflowData.SandboxConfig == nil || workflowData.SandboxConfig.Agent == nil { + if workflowData.ParsedTools == nil || workflowData.ParsedTools.GitHub == nil { return nil } - bq := workflowData.SandboxConfig.Agent.BoundedQueries + bq := workflowData.ParsedTools.GitHub.BoundedQueries if bq == nil { return nil } diff --git a/pkg/workflow/bounded_queries_test.go b/pkg/workflow/bounded_queries_test.go index 482a874f39e..9d1be81047c 100644 --- a/pkg/workflow/bounded_queries_test.go +++ b/pkg/workflow/bounded_queries_test.go @@ -26,7 +26,11 @@ func TestBuildAWFConfigJSON_BoundedQueries(t *testing.T) { }, SandboxConfig: &SandboxConfig{ Agent: &AgentSandboxConfig{ - ID: "awf", + ID: "awf", + }, + }, + ParsedTools: &ToolsConfig{ + GitHub: &GitHubToolConfig{ BoundedQueries: bq, }, }, @@ -156,29 +160,28 @@ func TestExtractBoundedQueriesConfig(t *testing.T) { assert.Nil(t, extractBoundedQueriesConfig(nil)) }) - t.Run("returns nil for missing sandbox config", func(t *testing.T) { + t.Run("returns nil for missing ParsedTools", func(t *testing.T) { assert.Nil(t, extractBoundedQueriesConfig(&WorkflowData{})) }) - t.Run("returns nil for missing agent config", func(t *testing.T) { + t.Run("returns nil for missing GitHub tool config", func(t *testing.T) { assert.Nil(t, extractBoundedQueriesConfig(&WorkflowData{ - SandboxConfig: &SandboxConfig{}, + ParsedTools: &ToolsConfig{}, })) }) t.Run("returns nil when bounded-queries is absent", func(t *testing.T) { assert.Nil(t, extractBoundedQueriesConfig(&WorkflowData{ - SandboxConfig: &SandboxConfig{ - Agent: &AgentSandboxConfig{ID: "awf"}, + ParsedTools: &ToolsConfig{ + GitHub: &GitHubToolConfig{}, }, })) }) t.Run("maps all fields correctly", func(t *testing.T) { data := &WorkflowData{ - SandboxConfig: &SandboxConfig{ - Agent: &AgentSandboxConfig{ - ID: "awf", + ParsedTools: &ToolsConfig{ + GitHub: &GitHubToolConfig{ BoundedQueries: &BoundedQueriesConfig{ PrivateRepos: []*BoundedQueryPrivateRepo{ {Repo: "my-org/internal-service", Sensitivity: "internal"}, @@ -212,21 +215,31 @@ func TestExtractBoundedQueriesConfig(t *testing.T) { // TestValidateBoundedQueriesConfig validates all validation rules for bounded queries. func TestValidateBoundedQueriesConfig(t *testing.T) { - validAWFAgent := func(bq *BoundedQueriesConfig) *AgentSandboxConfig { - return &AgentSandboxConfig{ID: "awf", BoundedQueries: bq} + // validAWFWorkflow returns a *WorkflowData with an AWF sandbox and the given bounded-queries config. + validAWFWorkflow := func(bq *BoundedQueriesConfig) *WorkflowData { + return &WorkflowData{ + SandboxConfig: &SandboxConfig{ + Agent: &AgentSandboxConfig{ID: "awf"}, + }, + ParsedTools: &ToolsConfig{ + GitHub: &GitHubToolConfig{ + BoundedQueries: bq, + }, + }, + } } t.Run("valid minimal config passes", func(t *testing.T) { - agent := validAWFAgent(&BoundedQueriesConfig{ + wd := validAWFWorkflow(&BoundedQueriesConfig{ PrivateRepos: []*BoundedQueryPrivateRepo{ {Repo: "my-org/my-repo", Sensitivity: "internal"}, }, }) - assert.NoError(t, validateBoundedQueriesConfig(agent)) + assert.NoError(t, validateBoundedQueriesConfig(wd)) }) t.Run("valid config with all optional fields passes", func(t *testing.T) { - agent := validAWFAgent(&BoundedQueriesConfig{ + wd := validAWFWorkflow(&BoundedQueriesConfig{ PrivateRepos: []*BoundedQueryPrivateRepo{ {Repo: "my-org/my-repo", Sensitivity: "confidential"}, }, @@ -236,133 +249,143 @@ func TestValidateBoundedQueriesConfig(t *testing.T) { Interpreter: "python3", MaxInvocations: 32, }) - assert.NoError(t, validateBoundedQueriesConfig(agent)) + assert.NoError(t, validateBoundedQueriesConfig(wd)) }) t.Run("all four sensitivity values are accepted", func(t *testing.T) { for _, sensitivity := range []string{"public", "internal", "confidential", "sealed"} { t.Run(sensitivity, func(t *testing.T) { - agent := validAWFAgent(&BoundedQueriesConfig{ + wd := validAWFWorkflow(&BoundedQueriesConfig{ PrivateRepos: []*BoundedQueryPrivateRepo{ {Repo: "my-org/my-repo", Sensitivity: sensitivity}, }, }) - assert.NoError(t, validateBoundedQueriesConfig(agent)) + assert.NoError(t, validateBoundedQueriesConfig(wd)) }) } }) - t.Run("nil agent or nil bounded-queries returns nil", func(t *testing.T) { + t.Run("nil WorkflowData returns nil", func(t *testing.T) { assert.NoError(t, validateBoundedQueriesConfig(nil)) - assert.NoError(t, validateBoundedQueriesConfig(&AgentSandboxConfig{ID: "awf"})) + }) + + t.Run("nil bounded-queries returns nil", func(t *testing.T) { + assert.NoError(t, validateBoundedQueriesConfig(&WorkflowData{ + SandboxConfig: &SandboxConfig{Agent: &AgentSandboxConfig{ID: "awf"}}, + ParsedTools: &ToolsConfig{GitHub: &GitHubToolConfig{}}, + })) }) t.Run("rejects non-AWF sandbox", func(t *testing.T) { - agent := &AgentSandboxConfig{ - // No ID / type set — will be empty string. - BoundedQueries: &BoundedQueriesConfig{ - PrivateRepos: []*BoundedQueryPrivateRepo{ - {Repo: "my-org/my-repo", Sensitivity: "internal"}, + wd := &WorkflowData{ + // No sandbox config — agent type will be empty string. + ParsedTools: &ToolsConfig{ + GitHub: &GitHubToolConfig{ + BoundedQueries: &BoundedQueriesConfig{ + PrivateRepos: []*BoundedQueryPrivateRepo{ + {Repo: "my-org/my-repo", Sensitivity: "internal"}, + }, + }, }, }, } - err := validateBoundedQueriesConfig(agent) + err := validateBoundedQueriesConfig(wd) require.Error(t, err) assert.Contains(t, err.Error(), "bounded-queries requires the AWF sandbox") }) t.Run("rejects empty private-repos", func(t *testing.T) { - agent := validAWFAgent(&BoundedQueriesConfig{ + wd := validAWFWorkflow(&BoundedQueriesConfig{ PrivateRepos: []*BoundedQueryPrivateRepo{}, }) - err := validateBoundedQueriesConfig(agent) + err := validateBoundedQueriesConfig(wd) require.Error(t, err) assert.Contains(t, err.Error(), "at least one private-repos entry") }) t.Run("rejects nil private-repos", func(t *testing.T) { - agent := validAWFAgent(&BoundedQueriesConfig{}) - err := validateBoundedQueriesConfig(agent) + wd := validAWFWorkflow(&BoundedQueriesConfig{}) + err := validateBoundedQueriesConfig(wd) require.Error(t, err) assert.Contains(t, err.Error(), "at least one private-repos entry") }) t.Run("rejects invalid sensitivity value", func(t *testing.T) { - agent := validAWFAgent(&BoundedQueriesConfig{ + wd := validAWFWorkflow(&BoundedQueriesConfig{ PrivateRepos: []*BoundedQueryPrivateRepo{ {Repo: "my-org/my-repo", Sensitivity: "top-secret"}, }, }) - err := validateBoundedQueriesConfig(agent) + err := validateBoundedQueriesConfig(wd) require.Error(t, err) assert.Contains(t, err.Error(), "sensitivity must be one of") }) t.Run("rejects duplicate repo slugs", func(t *testing.T) { - agent := validAWFAgent(&BoundedQueriesConfig{ + wd := validAWFWorkflow(&BoundedQueriesConfig{ PrivateRepos: []*BoundedQueryPrivateRepo{ {Repo: "my-org/my-repo", Sensitivity: "internal"}, {Repo: "my-org/my-repo", Sensitivity: "confidential"}, }, }) - err := validateBoundedQueriesConfig(agent) + err := validateBoundedQueriesConfig(wd) require.Error(t, err) assert.Contains(t, err.Error(), "duplicate repository slug") }) t.Run("rejects GitHub Actions expressions in repo slug", func(t *testing.T) { - agent := validAWFAgent(&BoundedQueriesConfig{ + wd := validAWFWorkflow(&BoundedQueriesConfig{ PrivateRepos: []*BoundedQueryPrivateRepo{ {Repo: "${{ inputs.repo }}", Sensitivity: "internal"}, }, }) - err := validateBoundedQueriesConfig(agent) + err := validateBoundedQueriesConfig(wd) require.Error(t, err) assert.Contains(t, err.Error(), "must not contain GitHub Actions expressions") }) t.Run("rejects malformed repo slug (missing slash)", func(t *testing.T) { - agent := validAWFAgent(&BoundedQueriesConfig{ + wd := validAWFWorkflow(&BoundedQueriesConfig{ PrivateRepos: []*BoundedQueryPrivateRepo{ {Repo: "myrepo", Sensitivity: "internal"}, }, }) - err := validateBoundedQueriesConfig(agent) + err := validateBoundedQueriesConfig(wd) require.Error(t, err) assert.Contains(t, err.Error(), "'owner/repo' format") }) t.Run("rejects empty repo slug", func(t *testing.T) { - agent := validAWFAgent(&BoundedQueriesConfig{ + wd := validAWFWorkflow(&BoundedQueriesConfig{ PrivateRepos: []*BoundedQueryPrivateRepo{ {Repo: "", Sensitivity: "internal"}, }, }) - err := validateBoundedQueriesConfig(agent) + err := validateBoundedQueriesConfig(wd) require.Error(t, err) assert.Contains(t, err.Error(), "must not be empty") }) t.Run("rejects unsupported runtime", func(t *testing.T) { - agent := validAWFAgent(&BoundedQueriesConfig{ + wd := validAWFWorkflow(&BoundedQueriesConfig{ PrivateRepos: []*BoundedQueryPrivateRepo{ {Repo: "my-org/my-repo", Sensitivity: "internal"}, }, Runtime: "podman", }) - err := validateBoundedQueriesConfig(agent) + err := validateBoundedQueriesConfig(wd) require.Error(t, err) assert.Contains(t, err.Error(), "unsupported bounded-queries runtime") }) t.Run("rejects negative timeout", func(t *testing.T) { - agent := validAWFAgent(&BoundedQueriesConfig{ + wd := validAWFWorkflow(&BoundedQueriesConfig{ PrivateRepos: []*BoundedQueryPrivateRepo{ {Repo: "my-org/my-repo", Sensitivity: "internal"}, }, Timeout: -1, }) - err := validateBoundedQueriesConfig(agent) + err := validateBoundedQueriesConfig(wd) require.Error(t, err) assert.Contains(t, err.Error(), "timeout must be a positive integer") }) @@ -370,13 +393,13 @@ func TestValidateBoundedQueriesConfig(t *testing.T) { t.Run("rejects invalid memory-limit format", func(t *testing.T) { for _, invalid := range []string{"512", "512mb", "5.5g", "abc"} { t.Run(invalid, func(t *testing.T) { - agent := validAWFAgent(&BoundedQueriesConfig{ + wd := validAWFWorkflow(&BoundedQueriesConfig{ PrivateRepos: []*BoundedQueryPrivateRepo{ {Repo: "my-org/my-repo", Sensitivity: "internal"}, }, MemoryLimit: invalid, }) - err := validateBoundedQueriesConfig(agent) + err := validateBoundedQueriesConfig(wd) require.Error(t, err) assert.Contains(t, err.Error(), "memory-limit") }) @@ -386,37 +409,37 @@ func TestValidateBoundedQueriesConfig(t *testing.T) { t.Run("accepts valid memory-limit formats", func(t *testing.T) { for _, valid := range []string{"512m", "2g", "1024k", "512M", "2G"} { t.Run(valid, func(t *testing.T) { - agent := validAWFAgent(&BoundedQueriesConfig{ + wd := validAWFWorkflow(&BoundedQueriesConfig{ PrivateRepos: []*BoundedQueryPrivateRepo{ {Repo: "my-org/my-repo", Sensitivity: "internal"}, }, MemoryLimit: valid, }) - assert.NoError(t, validateBoundedQueriesConfig(agent)) + assert.NoError(t, validateBoundedQueriesConfig(wd)) }) } }) t.Run("rejects unsupported interpreter", func(t *testing.T) { - agent := validAWFAgent(&BoundedQueriesConfig{ + wd := validAWFWorkflow(&BoundedQueriesConfig{ PrivateRepos: []*BoundedQueryPrivateRepo{ {Repo: "my-org/my-repo", Sensitivity: "internal"}, }, Interpreter: "ruby", }) - err := validateBoundedQueriesConfig(agent) + err := validateBoundedQueriesConfig(wd) require.Error(t, err) assert.Contains(t, err.Error(), "unsupported bounded-queries interpreter") }) t.Run("rejects negative max-invocations", func(t *testing.T) { - agent := validAWFAgent(&BoundedQueriesConfig{ + wd := validAWFWorkflow(&BoundedQueriesConfig{ PrivateRepos: []*BoundedQueryPrivateRepo{ {Repo: "my-org/my-repo", Sensitivity: "internal"}, }, MaxInvocations: -1, }) - err := validateBoundedQueriesConfig(agent) + err := validateBoundedQueriesConfig(wd) require.Error(t, err) assert.Contains(t, err.Error(), "max-invocations must be a positive integer") }) diff --git a/pkg/workflow/compiler_validators.go b/pkg/workflow/compiler_validators.go index 46d5f28b4af..27e23594a9c 100644 --- a/pkg/workflow/compiler_validators.go +++ b/pkg/workflow/compiler_validators.go @@ -194,6 +194,7 @@ func (c *Compiler) validateCoreToolConfiguration(workflowData *WorkflowData, mar {logMessage: "Validating private-to-public-flows server IDs", validateFn: func() error { return validatePrivateToPublicFlowsServerIDs(workflowData) }}, {logMessage: "Validating GCP WIF engine auth required fields", validateFn: func() error { return validateGCPWIFEngineAuth(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) }}, } // This validation is intentionally outside the table below because strict mode // turns the same validation result into either an error or a warning. diff --git a/pkg/workflow/sandbox.go b/pkg/workflow/sandbox.go index 57fc051e798..b1c282f07b2 100644 --- a/pkg/workflow/sandbox.go +++ b/pkg/workflow/sandbox.go @@ -62,84 +62,24 @@ const ( // AgentSandboxConfig represents the agent sandbox configuration type AgentSandboxConfig struct { - ID string `yaml:"id,omitempty"` // Agent ID: "awf" or "srt" (replaces Type in new object format) - Type SandboxType `yaml:"type,omitempty"` // Sandbox type: "awf" or "srt" (legacy, use ID instead) - Version string `yaml:"version,omitempty"` // AWF version override used to install and run the matching firewall version - Platform string `yaml:"platform,omitempty"` // AWF platform.type override (github.com, ghes, ghec, ghec-self-hosted) - Runtime AgentRuntime `yaml:"runtime,omitempty"` // Container runtime for the agent container (e.g., "gvisor") - NetworkIsolation bool `yaml:"sudo,omitempty"` // Internal: true = isolation mode (AWF --network-isolation). Frontmatter sudo: false (or omitted) maps to NetworkIsolation=true; sudo: true maps to NetworkIsolation=false. - SudoExplicitlyEnabled bool `yaml:"-"` // True when sudo: true was explicitly set in frontmatter. Used to emit an error (strict) or warning (non-strict) at compile time. - LegacySecurity bool `yaml:"-"` // True when legacy-security: enable was set in frontmatter. Enables sudo, host-access, and iptables-based mode. - Disabled bool `yaml:"-"` // True when agent is explicitly set to false (disables firewall). This is a runtime flag, not serialized to YAML. - DisableReason string `yaml:"-"` // Operator-authored justification from dangerously-disable-sandbox-agent feature; available for diagnostics and audit logging. - Config *SandboxRuntimeConfig `yaml:"config,omitempty"` // Custom SRT config (optional) - Command string `yaml:"command,omitempty"` // Custom command to replace AWF or SRT installation - Args []string `yaml:"args,omitempty"` // Additional arguments to append to the command - Env map[string]string `yaml:"env,omitempty"` // Environment variables to set on the step - Mounts []string `yaml:"mounts,omitempty"` // Container mounts to add for AWF (format: "source:dest:mode") - Memory string `yaml:"memory,omitempty"` // Memory limit for the AWF container (e.g., "4g", "8g") - ModelFallback *TemplatableBool `yaml:"model-fallback,omitempty"` // AWF API proxy model fallback enable/disable flag (optional) - Targets map[string]*AgentAPIProxyTargetConfig `yaml:"targets,omitempty"` // Per-provider API proxy target overrides keyed by provider name (e.g. "openai", "anthropic") - BoundedQueries *BoundedQueriesConfig `yaml:"bounded-queries,omitempty"` // Bounded-query configuration for cross-repository private data access -} - -// BoundedQueriesConfig configures the AWF bounded-query subsystem, which allows the agent -// to answer finite, pre-approved questions about private repositories without receiving -// raw source content. The presence of this block enables the feature. -// -// Example frontmatter: -// -// sandbox: -// agent: -// id: awf -// bounded-queries: -// private-repos: -// - repo: my-org/internal-service -// sensitivity: internal -// runtime: docker -// timeout: 30 -// memory-limit: 512m -// interpreter: python3 -// max-invocations: 32 -type BoundedQueriesConfig struct { - // PrivateRepos is the list of private repositories that the agent may query. - // At least one entry is required when bounded-queries is configured. - // Each entry must have a valid "owner/repo" slug and a sensitivity classification. - PrivateRepos []*BoundedQueryPrivateRepo `yaml:"private-repos,omitempty"` - - // Runtime is the container runtime used to execute bounded-query scripts. - // Optional; when omitted AWF uses its default runtime. - // Supported values: "docker" - Runtime string `yaml:"runtime,omitempty"` - - // Timeout is the maximum execution time in seconds for a single bounded-query invocation. - // Optional; when omitted AWF uses its default timeout. - // Must be a positive integer. - Timeout int `yaml:"timeout,omitempty"` - - // MemoryLimit is the memory limit for bounded-query container execution (e.g. "512m", "1g"). - // Optional; when omitted AWF uses its default memory limit. - MemoryLimit string `yaml:"memory-limit,omitempty"` - - // Interpreter is the script interpreter for bounded-query execution (e.g. "python3"). - // Optional; when omitted AWF uses its default interpreter. - Interpreter string `yaml:"interpreter,omitempty"` - - // MaxInvocations is the maximum number of bounded-query invocations allowed per run. - // Optional; when omitted AWF uses its default. - // Must be a positive integer. - MaxInvocations int `yaml:"max-invocations,omitempty"` -} - -// BoundedQueryPrivateRepo describes one private repository approved for bounded-query access. -type BoundedQueryPrivateRepo struct { - // Repo is the "owner/repo" slug of the private repository. - // Must not contain GitHub Actions expressions. - Repo string `yaml:"repo"` - - // Sensitivity is the confidentiality classification for this repository. - // Accepted values: "public", "internal", "confidential", "sealed". - Sensitivity string `yaml:"sensitivity"` + ID string `yaml:"id,omitempty"` // Agent ID: "awf" or "srt" (replaces Type in new object format) + Type SandboxType `yaml:"type,omitempty"` // Sandbox type: "awf" or "srt" (legacy, use ID instead) + Version string `yaml:"version,omitempty"` // AWF version override used to install and run the matching firewall version + Platform string `yaml:"platform,omitempty"` // AWF platform.type override (github.com, ghes, ghec, ghec-self-hosted) + Runtime AgentRuntime `yaml:"runtime,omitempty"` // Container runtime for the agent container (e.g., "gvisor") + NetworkIsolation bool `yaml:"sudo,omitempty"` // Internal: true = isolation mode (AWF --network-isolation). Frontmatter sudo: false (or omitted) maps to NetworkIsolation=true; sudo: true maps to NetworkIsolation=false. + SudoExplicitlyEnabled bool `yaml:"-"` // True when sudo: true was explicitly set in frontmatter. Used to emit an error (strict) or warning (non-strict) at compile time. + LegacySecurity bool `yaml:"-"` // True when legacy-security: enable was set in frontmatter. Enables sudo, host-access, and iptables-based mode. + Disabled bool `yaml:"-"` // True when agent is explicitly set to false (disables firewall). This is a runtime flag, not serialized to YAML. + DisableReason string `yaml:"-"` // Operator-authored justification from dangerously-disable-sandbox-agent feature; available for diagnostics and audit logging. + Config *SandboxRuntimeConfig `yaml:"config,omitempty"` // Custom SRT config (optional) + Command string `yaml:"command,omitempty"` // Custom command to replace AWF or SRT installation + Args []string `yaml:"args,omitempty"` // Additional arguments to append to the command + Env map[string]string `yaml:"env,omitempty"` // Environment variables to set on the step + Mounts []string `yaml:"mounts,omitempty"` // Container mounts to add for AWF (format: "source:dest:mode") + Memory string `yaml:"memory,omitempty"` // Memory limit for the AWF container (e.g., "4g", "8g") + ModelFallback *TemplatableBool `yaml:"model-fallback,omitempty"` // AWF API proxy model fallback enable/disable flag (optional) + Targets map[string]*AgentAPIProxyTargetConfig `yaml:"targets,omitempty"` // Per-provider API proxy target overrides keyed by provider name (e.g. "openai", "anthropic") } // AiCreditsPricingConfig holds per-token pricing rates ($/1M tokens) used as a fallback diff --git a/pkg/workflow/sandbox_validation.go b/pkg/workflow/sandbox_validation.go index e79399c9493..aae5e4941af 100644 --- a/pkg/workflow/sandbox_validation.go +++ b/pkg/workflow/sandbox_validation.go @@ -3,7 +3,7 @@ // This file contains domain-specific validation functions for sandbox configuration: // - validateMountsSyntax() - Validates container mount syntax // - validateSandboxConfig() - Validates complete sandbox configuration -// - validateBoundedQueriesConfig() - Validates bounded-query configuration +// - validateBoundedQueriesConfig() - Validates tools.github.bounded-queries configuration // // These validation functions are organized in a dedicated file following the validation // architecture pattern where domain-specific validation belongs in domain validation files. @@ -210,13 +210,6 @@ func validateSandboxConfig(workflowData *WorkflowData) error { sandboxValidationLog.Print("Agent sandbox enabled with MCP gateway - validation passed") } - // Validate bounded-queries configuration when present. - if sandboxConfig.Agent != nil && sandboxConfig.Agent.BoundedQueries != nil { - if err := validateBoundedQueriesConfig(sandboxConfig.Agent); err != nil { - return err - } - } - return nil } @@ -238,40 +231,45 @@ var validBoundedQueryInterpreters = map[string]struct{}{ "python3": {}, } -// validateBoundedQueriesConfig validates sandbox.agent.bounded-queries configuration. +// validateBoundedQueriesConfig validates tools.github.bounded-queries configuration. // Returns an error when the configuration is invalid. -func validateBoundedQueriesConfig(agentConfig *AgentSandboxConfig) error { - if agentConfig == nil || agentConfig.BoundedQueries == nil { +func validateBoundedQueriesConfig(workflowData *WorkflowData) error { + if workflowData == nil || workflowData.ParsedTools == nil || workflowData.ParsedTools.GitHub == nil { + return nil + } + bq := workflowData.ParsedTools.GitHub.BoundedQueries + if bq == nil { return nil } // bounded-queries is only supported for the AWF sandbox. - agentType := getAgentType(agentConfig) + var agentType SandboxType + if workflowData.SandboxConfig != nil && workflowData.SandboxConfig.Agent != nil { + agentType = getAgentType(workflowData.SandboxConfig.Agent) + } if !isSupportedSandboxType(agentType) { return NewValidationError( - "sandbox.agent.bounded-queries", + "tools.github.bounded-queries", string(agentType), "bounded-queries requires the AWF sandbox (sandbox.agent.id: awf)", - "Set sandbox.agent.id: awf when using bounded-queries:\n\nsandbox:\n agent:\n id: awf\n bounded-queries:\n private-repos:\n - repo: my-org/my-repo\n sensitivity: internal\n\nSee: "+string(constants.DocsSandboxURL), + "Set sandbox.agent.id: awf when using bounded-queries:\n\nsandbox:\n agent:\n id: awf\ntools:\n github:\n bounded-queries:\n private-repos:\n - repo: my-org/my-repo\n sensitivity: internal\n\nSee: "+string(constants.DocsSandboxURL), ) } - bq := agentConfig.BoundedQueries - // Validate that private-repos is non-empty. if len(bq.PrivateRepos) == 0 { return NewValidationError( - "sandbox.agent.bounded-queries.private-repos", + "tools.github.bounded-queries.private-repos", "[]", "bounded-queries requires at least one private-repos entry", - "Add at least one repository to private-repos:\n\nsandbox:\n agent:\n bounded-queries:\n private-repos:\n - repo: my-org/my-repo\n sensitivity: internal\n\nSee: "+string(constants.DocsSandboxURL), + "Add at least one repository to private-repos:\n\ntools:\n github:\n bounded-queries:\n private-repos:\n - repo: my-org/my-repo\n sensitivity: internal\n\nSee: "+string(constants.DocsSandboxURL), ) } // Validate each private-repo entry. seen := make(map[string]struct{}, len(bq.PrivateRepos)) for i, r := range bq.PrivateRepos { - field := fmt.Sprintf("sandbox.agent.bounded-queries.private-repos[%d]", i) + field := fmt.Sprintf("tools.github.bounded-queries.private-repos[%d]", i) if r == nil { return NewValidationError(field, "", "private-repos entry must not be null", "") @@ -289,7 +287,7 @@ func validateBoundedQueriesConfig(agentConfig *AgentSandboxConfig) error { field+".sensitivity", r.Sensitivity, "sensitivity must be one of: "+validValues, - "Use one of the accepted sensitivity values:\n\nsandbox:\n agent:\n bounded-queries:\n private-repos:\n - repo: my-org/my-repo\n sensitivity: internal # one of: "+validValues+"\n\nSee: "+string(constants.DocsSandboxURL), + "Use one of the accepted sensitivity values:\n\ntools:\n github:\n bounded-queries:\n private-repos:\n - repo: my-org/my-repo\n sensitivity: internal # one of: "+validValues+"\n\nSee: "+string(constants.DocsSandboxURL), ) } @@ -310,10 +308,10 @@ func validateBoundedQueriesConfig(agentConfig *AgentSandboxConfig) error { if bq.Runtime != "" { if _, ok := validBoundedQueryRuntimes[bq.Runtime]; !ok { return NewValidationError( - "sandbox.agent.bounded-queries.runtime", + "tools.github.bounded-queries.runtime", bq.Runtime, "unsupported bounded-queries runtime: must be \"docker\"", - fmt.Sprintf("Set runtime to a supported value:\n\nsandbox:\n agent:\n bounded-queries:\n runtime: docker\n\nSee: %s", constants.DocsSandboxURL), + fmt.Sprintf("Set runtime to a supported value:\n\ntools:\n github:\n bounded-queries:\n runtime: docker\n\nSee: %s", constants.DocsSandboxURL), ) } } @@ -321,7 +319,7 @@ func validateBoundedQueriesConfig(agentConfig *AgentSandboxConfig) error { // Validate optional timeout. if bq.Timeout < 0 { return NewValidationError( - "sandbox.agent.bounded-queries.timeout", + "tools.github.bounded-queries.timeout", strconv.Itoa(bq.Timeout), "bounded-queries timeout must be a positive integer", fmt.Sprintf("Set timeout to a positive number of seconds.\n\nSee: %s", constants.DocsSandboxURL), @@ -339,10 +337,10 @@ func validateBoundedQueriesConfig(agentConfig *AgentSandboxConfig) error { if bq.Interpreter != "" { if _, ok := validBoundedQueryInterpreters[bq.Interpreter]; !ok { return NewValidationError( - "sandbox.agent.bounded-queries.interpreter", + "tools.github.bounded-queries.interpreter", bq.Interpreter, "unsupported bounded-queries interpreter: must be \"python3\"", - fmt.Sprintf("Set interpreter to a supported value:\n\nsandbox:\n agent:\n bounded-queries:\n interpreter: python3\n\nSee: %s", constants.DocsSandboxURL), + fmt.Sprintf("Set interpreter to a supported value:\n\ntools:\n github:\n bounded-queries:\n interpreter: python3\n\nSee: %s", constants.DocsSandboxURL), ) } } @@ -350,7 +348,7 @@ func validateBoundedQueriesConfig(agentConfig *AgentSandboxConfig) error { // Validate optional max-invocations. if bq.MaxInvocations < 0 { return NewValidationError( - "sandbox.agent.bounded-queries.max-invocations", + "tools.github.bounded-queries.max-invocations", strconv.Itoa(bq.MaxInvocations), "bounded-queries max-invocations must be a positive integer", fmt.Sprintf("Set max-invocations to a positive integer.\n\nSee: %s", constants.DocsSandboxURL), @@ -398,10 +396,10 @@ var memoryLimitPattern = regexp.MustCompile(`^\d+[kmgKMG]$`) func validateBoundedQueryMemoryLimit(memoryLimit string) error { if !memoryLimitPattern.MatchString(memoryLimit) { return NewValidationError( - "sandbox.agent.bounded-queries.memory-limit", + "tools.github.bounded-queries.memory-limit", memoryLimit, "memory-limit must be a number followed by a unit: k, m, or g (e.g. \"512m\", \"2g\")", - fmt.Sprintf("Use a valid memory limit format:\n\nsandbox:\n agent:\n bounded-queries:\n memory-limit: 512m # examples: 512m, 2g, 1024k\n\nSee: %s", constants.DocsSandboxURL), + fmt.Sprintf("Use a valid memory limit format:\n\ntools:\n github:\n bounded-queries:\n memory-limit: 512m # examples: 512m, 2g, 1024k\n\nSee: %s", constants.DocsSandboxURL), ) } return nil diff --git a/pkg/workflow/tools_parser.go b/pkg/workflow/tools_parser.go index a4dc540ea83..2801e47e845 100644 --- a/pkg/workflow/tools_parser.go +++ b/pkg/workflow/tools_parser.go @@ -412,6 +412,13 @@ func parseGitHubTool(val any) *GitHubToolConfig { } } + // Parse bounded-queries configuration. + if rawBQ, ok := configMap["bounded-queries"]; ok { + if bqMap, ok := rawBQ.(map[string]any); ok { + config.BoundedQueries = parseBoundedQueriesConfig(bqMap) + } + } + return config } @@ -420,7 +427,44 @@ func parseGitHubTool(val any) *GitHubToolConfig { } } -// parseBashTool converts raw bash tool configuration to BashToolConfig +// parseBoundedQueriesConfig converts a raw map into a BoundedQueriesConfig. +func parseBoundedQueriesConfig(bqMap map[string]any) *BoundedQueriesConfig { + config := &BoundedQueriesConfig{} + + if rawRepos, ok := bqMap["private-repos"].([]any); ok { + config.PrivateRepos = make([]*BoundedQueryPrivateRepo, 0, len(rawRepos)) + for _, item := range rawRepos { + if repoMap, ok := item.(map[string]any); ok { + entry := &BoundedQueryPrivateRepo{} + if repo, ok := repoMap["repo"].(string); ok { + entry.Repo = repo + } + if sensitivity, ok := repoMap["sensitivity"].(string); ok { + entry.Sensitivity = sensitivity + } + config.PrivateRepos = append(config.PrivateRepos, entry) + } + } + } + + if runtime, ok := bqMap["runtime"].(string); ok { + config.Runtime = runtime + } + if timeout, ok := bqMap["timeout"].(int); ok { + config.Timeout = timeout + } + if memoryLimit, ok := bqMap["memory-limit"].(string); ok { + config.MemoryLimit = memoryLimit + } + if interpreter, ok := bqMap["interpreter"].(string); ok { + config.Interpreter = interpreter + } + if maxInvocations, ok := bqMap["max-invocations"].(int); ok { + config.MaxInvocations = maxInvocations + } + + return config +} func parseBashTool(val any) *BashToolConfig { if val == nil { // nil is no longer supported - return nil to indicate invalid configuration diff --git a/pkg/workflow/tools_types.go b/pkg/workflow/tools_types.go index 25c6d56a6ac..2c1fd54be51 100644 --- a/pkg/workflow/tools_types.go +++ b/pkg/workflow/tools_types.go @@ -373,6 +373,70 @@ type GitHubToolConfig struct { // - []string → compiler emits gateway.sinkVisibilityExemptServers with the listed IDs. // See MCP Gateway Specification Section 10.9. PrivateToPublicFlows any `yaml:"-"` + + // BoundedQueries configures the AWF bounded-query subsystem for cross-repository + // private data access. When set, the agent may answer finite, pre-approved questions + // about the listed repositories without receiving raw source code. + // Requires the AWF sandbox (sandbox.agent.id: awf) and AWF v0.28.0+. + BoundedQueries *BoundedQueriesConfig `yaml:"bounded-queries,omitempty"` +} + +// BoundedQueriesConfig configures the AWF bounded-query subsystem, which allows the agent +// to answer finite, pre-approved questions about private repositories without receiving +// raw source content. The presence of this block enables the feature. +// +// Example frontmatter: +// +// tools: +// github: +// bounded-queries: +// private-repos: +// - repo: my-org/internal-service +// sensitivity: internal +// runtime: docker +// timeout: 30 +// memory-limit: 512m +// interpreter: python3 +// max-invocations: 32 +type BoundedQueriesConfig struct { + // PrivateRepos is the list of private repositories that the agent may query. + // At least one entry is required when bounded-queries is configured. + // Each entry must have a valid "owner/repo" slug and a sensitivity classification. + PrivateRepos []*BoundedQueryPrivateRepo `yaml:"private-repos,omitempty"` + + // Runtime is the container runtime used to execute bounded-query scripts. + // Optional; when omitted AWF uses its default runtime. + // Supported values: "docker" + Runtime string `yaml:"runtime,omitempty"` + + // Timeout is the maximum execution time in seconds for a single bounded-query invocation. + // Optional; when omitted AWF uses its default timeout. + // Must be a positive integer. + Timeout int `yaml:"timeout,omitempty"` + + // MemoryLimit is the memory limit for bounded-query container execution (e.g. "512m", "1g"). + // Optional; when omitted AWF uses its default memory limit. + MemoryLimit string `yaml:"memory-limit,omitempty"` + + // Interpreter is the script interpreter for bounded-query execution (e.g. "python3"). + // Optional; when omitted AWF uses its default interpreter. + Interpreter string `yaml:"interpreter,omitempty"` + + // MaxInvocations is the maximum number of bounded-query invocations allowed per run. + // Optional; when omitted AWF uses its default. + // Must be a positive integer. + MaxInvocations int `yaml:"max-invocations,omitempty"` +} + +// BoundedQueryPrivateRepo describes one private repository approved for bounded-query access. +type BoundedQueryPrivateRepo struct { + // Repo is the "owner/repo" slug of the private repository. + // Must not contain GitHub Actions expressions. + Repo string `yaml:"repo"` + + // Sensitivity is the confidentiality classification for this repository. + // Accepted values: "public", "internal", "confidential", "sealed". + Sensitivity string `yaml:"sensitivity"` } // PlaywrightToolConfig represents the configuration for the Playwright tool From 47ce8213b7d9e65ce7494e6f6bd74027073d7796 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 30 Jul 2026 16:02:04 +0000 Subject: [PATCH 4/5] fix: address code review feedback on bounded queries implementation Co-authored-by: gh-aw-bot <259018956+gh-aw-bot@users.noreply.github.com> --- .github/aw/create-agentic-workflow.md | 16 +++--- .github/aw/designer.md | 2 + .github/aw/syntax-agentic.md | 2 +- pkg/parser/schemas/main_workflow_schema.json | 56 ++++++++++++++++++++ pkg/workflow/bounded_queries_test.go | 22 ++++++++ pkg/workflow/sandbox_validation.go | 9 ++-- pkg/workflow/schemas/awf-config.schema.json | 2 +- 7 files changed, 97 insertions(+), 12 deletions(-) diff --git a/.github/aw/create-agentic-workflow.md b/.github/aw/create-agentic-workflow.md index 8c21e41e45c..77b14174722 100644 --- a/.github/aw/create-agentic-workflow.md +++ b/.github/aw/create-agentic-workflow.md @@ -307,12 +307,16 @@ Before finalizing any newly generated workflow, verify: ## Multi-Repository Requests -For cross-repository workflows: - -- enable the GitHub toolsets needed to read external repositories -- configure cross-repo authentication in `safe-outputs:` -- tell the agent to set `target-repo` -- explain that the workflow still cannot wait for external workflows or create multi-job orchestration +For cross-repository workflows, first determine whether the question is **finite and bounded**: + +- If the agent needs to answer a finite, pre-approved question about a private repository (e.g. "does this repo have open critical issues?", "what is the latest release version?"): + - Use `tools.github.bounded-queries` with `private-repos` and `sandbox.agent.id: awf` (AWF v0.28.0+) + - This is the preferred approach — no raw source code is exposed and no cross-repo token is needed +- If the answer is unbounded (e.g. arbitrary source-code extraction, full file contents), or if bounded queries are not appropriate: + - enable the GitHub toolsets needed to read external repositories + - configure cross-repo authentication in `safe-outputs:` + - tell the agent to set `target-repo` + - explain that the workflow still cannot wait for external workflows or create multi-job orchestration Use [workflow-patterns.md](workflow-patterns.md) for the compact cross-repo pattern. diff --git a/.github/aw/designer.md b/.github/aw/designer.md index d2f0e541453..6929de6e062 100644 --- a/.github/aw/designer.md +++ b/.github/aw/designer.md @@ -209,6 +209,7 @@ Present a structured summary and ask for approval before generation. | "run commands/tests" | `bash` tool (default unless restricted) | | "browse web pages/docs" | `web-fetch` and/or `web-search` | | "test UI flows" | `playwright` | +| "finite question about private repo" | `tools.github.bounded-queries` (AWF v0.28.0+, preferred over cross-repo tokens) | ### Pattern Heuristics @@ -267,6 +268,7 @@ Never suggest committing plaintext tokens. | "just respond to a comment" | no pre-fetch needed (event payload is enough) | | "process each item individually" | suggest sub-agent pattern with `model: small` | | "weekly digest", "compliance report", "license review", "policy audit" | pre-fetch with `gh` + `jq` into `/tmp/gh-aw/data/`; point prompt to those files | +| "finite question about a private repo", "check if private repo has X" | `tools.github.bounded-queries` (preferred over cross-repo token/checkout) | ## Token Optimization Defaults diff --git a/.github/aw/syntax-agentic.md b/.github/aw/syntax-agentic.md index 5ad26601731..7898c3d82e6 100644 --- a/.github/aw/syntax-agentic.md +++ b/.github/aw/syntax-agentic.md @@ -328,7 +328,7 @@ description: Agentic workflow specific frontmatter fields for GitHub Agentic Wor sensitivity: public # public | internal | confidential | sealed - repo: my-org/internal-service sensitivity: internal - runtime: docker # optional; default: AWF default + runtime: docker # optional; docker | gvisor; default: AWF default timeout: 30 # optional; seconds; default: AWF default memory-limit: 512m # optional; e.g. 512m, 2g; default: AWF default interpreter: python3 # optional; default: AWF default diff --git a/pkg/parser/schemas/main_workflow_schema.json b/pkg/parser/schemas/main_workflow_schema.json index 679c31c4013..bc1532a773e 100644 --- a/pkg/parser/schemas/main_workflow_schema.json +++ b/pkg/parser/schemas/main_workflow_schema.json @@ -4243,6 +4243,62 @@ "features": { "type": "string", "description": "Comma-separated list of GitHub MCP server feature flags to enable. Forwarded as GITHUB_FEATURES (Docker/local) or X-MCP-Features (remote). When omitted, 'fields_param' is enabled by default for server v1.6.0 and later. Set to an empty string to disable all feature flags." + }, + "bounded-queries": { + "type": "object", + "description": "AWF bounded-query configuration for cross-repository private data access (AWF v0.28.0+). Requires the AWF sandbox (sandbox.agent.id: awf).", + "additionalProperties": false, + "required": ["private-repos"], + "properties": { + "private-repos": { + "type": "array", + "description": "List of private repositories the agent may query via bounded queries.", + "minItems": 1, + "items": { + "type": "object", + "additionalProperties": false, + "required": ["repo", "sensitivity"], + "properties": { + "repo": { + "type": "string", + "description": "Repository slug in 'owner/repo' format.", + "pattern": "^[^/]+/[^/]+$", + "minLength": 3 + }, + "sensitivity": { + "type": "string", + "description": "Confidentiality classification for this repository.", + "enum": ["public", "internal", "confidential", "sealed"] + } + } + } + }, + "runtime": { + "type": "string", + "description": "Container runtime used to execute bounded-query scripts. When omitted AWF uses its default.", + "enum": ["docker", "gvisor"] + }, + "timeout": { + "type": "integer", + "description": "Maximum execution time in seconds for a single bounded-query invocation. When omitted AWF uses its default.", + "minimum": 1 + }, + "memory-limit": { + "type": "string", + "description": "Memory limit for bounded-query container execution (e.g. \"512m\", \"2g\"). When omitted AWF uses its default.", + "pattern": "^\\d+[kmgKMG]$" + }, + "interpreter": { + "type": "string", + "description": "Script interpreter for bounded-query execution. When omitted AWF uses its default.", + "enum": ["python3"] + }, + "max-invocations": { + "type": "integer", + "description": "Maximum number of bounded-query invocations allowed per run. When omitted AWF uses its default.", + "minimum": 1 + } + } } }, "additionalProperties": false, diff --git a/pkg/workflow/bounded_queries_test.go b/pkg/workflow/bounded_queries_test.go index 9d1be81047c..858b20d8fe4 100644 --- a/pkg/workflow/bounded_queries_test.go +++ b/pkg/workflow/bounded_queries_test.go @@ -333,6 +333,18 @@ func TestValidateBoundedQueriesConfig(t *testing.T) { assert.Contains(t, err.Error(), "duplicate repository slug") }) + t.Run("rejects duplicate repo slugs case-insensitively", func(t *testing.T) { + wd := validAWFWorkflow(&BoundedQueriesConfig{ + PrivateRepos: []*BoundedQueryPrivateRepo{ + {Repo: "my-org/my-repo", Sensitivity: "internal"}, + {Repo: "My-Org/My-Repo", Sensitivity: "confidential"}, + }, + }) + err := validateBoundedQueriesConfig(wd) + require.Error(t, err) + assert.Contains(t, err.Error(), "duplicate repository slug") + }) + t.Run("rejects GitHub Actions expressions in repo slug", func(t *testing.T) { wd := validAWFWorkflow(&BoundedQueriesConfig{ PrivateRepos: []*BoundedQueryPrivateRepo{ @@ -378,6 +390,16 @@ func TestValidateBoundedQueriesConfig(t *testing.T) { assert.Contains(t, err.Error(), "unsupported bounded-queries runtime") }) + t.Run("accepts gvisor runtime", func(t *testing.T) { + wd := validAWFWorkflow(&BoundedQueriesConfig{ + PrivateRepos: []*BoundedQueryPrivateRepo{ + {Repo: "my-org/my-repo", Sensitivity: "internal"}, + }, + Runtime: "gvisor", + }) + assert.NoError(t, validateBoundedQueriesConfig(wd)) + }) + t.Run("rejects negative timeout", func(t *testing.T) { wd := validAWFWorkflow(&BoundedQueriesConfig{ PrivateRepos: []*BoundedQueryPrivateRepo{ diff --git a/pkg/workflow/sandbox_validation.go b/pkg/workflow/sandbox_validation.go index aae5e4941af..75221827013 100644 --- a/pkg/workflow/sandbox_validation.go +++ b/pkg/workflow/sandbox_validation.go @@ -224,6 +224,7 @@ var validBoundedQuerySensitivities = map[string]struct{}{ // validBoundedQueryRuntimes is the set of accepted container runtimes. var validBoundedQueryRuntimes = map[string]struct{}{ "docker": {}, + "gvisor": {}, } // validBoundedQueryInterpreters is the set of accepted script interpreters. @@ -291,8 +292,8 @@ func validateBoundedQueriesConfig(workflowData *WorkflowData) error { ) } - // Validate no duplicates. - key := r.Repo + // Validate no duplicates (case-insensitive, matching AWF's treatment of slugs). + key := strings.ToLower(r.Repo) if _, dup := seen[key]; dup { return NewValidationError( field+".repo", @@ -310,8 +311,8 @@ func validateBoundedQueriesConfig(workflowData *WorkflowData) error { return NewValidationError( "tools.github.bounded-queries.runtime", bq.Runtime, - "unsupported bounded-queries runtime: must be \"docker\"", - fmt.Sprintf("Set runtime to a supported value:\n\ntools:\n github:\n bounded-queries:\n runtime: docker\n\nSee: %s", constants.DocsSandboxURL), + "unsupported bounded-queries runtime: must be \"docker\" or \"gvisor\"", + fmt.Sprintf("Set runtime to a supported value:\n\ntools:\n github:\n bounded-queries:\n runtime: docker # or gvisor\n\nSee: %s", constants.DocsSandboxURL), ) } } diff --git a/pkg/workflow/schemas/awf-config.schema.json b/pkg/workflow/schemas/awf-config.schema.json index 0fe063e0972..7582ea7c463 100644 --- a/pkg/workflow/schemas/awf-config.schema.json +++ b/pkg/workflow/schemas/awf-config.schema.json @@ -802,7 +802,7 @@ "runtime": { "type": "string", "description": "Container runtime used to execute bounded-query scripts. When omitted AWF uses its default.", - "enum": ["docker"] + "enum": ["docker", "gvisor"] }, "timeout": { "type": "integer", From a5dfd8caa0155fde38b68b751b30922b17c4e739 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 30 Jul 2026 16:30:17 +0000 Subject: [PATCH 5/5] fix: address 4 issues in bounded queries implementation Issue 1: Surface malformed frontmatter parse errors - Add ParseError field to BoundedQueriesConfig - In parseGitHubTool: create sentinel config when bounded-queries has wrong type - In parseBoundedQueriesConfig: set ParseError for wrong-type private-repos, non-map items, and wrong-type timeout/max-invocations - In validateBoundedQueriesConfig: reject configs with non-empty ParseError Issue 2: Fix validation limits to match AWF contract - Change Timeout and MaxInvocations from int to *int to distinguish unset (nil) from explicitly-set-to-zero (which is now rejected) - Timeout: enforce range 1-540 (AWF contract); rejects 0 and > 540 - MaxInvocations: enforce range 1-10000 (AWF contract); rejects 0 and > 10000 - memory-limit pattern: ^[1-9][0-9]*[bkmgBKMG]$ (reject leading zeros, reject 0m/0k, allow b/B unit, match AWF's accepted format) - Update both awf-config.schema.json and main_workflow_schema.json Issue 3: Unsupported AWF versions now fail compilation - validateBoundedQueriesConfig checks awfSupportsBoundedQueries() and returns a hard validation error with actionable version guidance instead of silently omitting the bounded-queries section Issue 4: Accurate sensitivity documentation - Document actual disclosure budgets: public=unmetered, internal=64 bits/run, confidential=8 bits/run, sealed=0 bits/run (cannot fund a query) Co-authored-by: lpcox <15877973+lpcox@users.noreply.github.com> --- .github/aw/syntax-agentic.md | 8 +- pkg/parser/schemas/main_workflow_schema.json | 10 +- pkg/workflow/awf_config.go | 16 +- pkg/workflow/bounded_queries_test.go | 223 ++++++++++++++++--- pkg/workflow/sandbox_validation.go | 79 +++++-- pkg/workflow/schemas/awf-config.schema.json | 8 +- pkg/workflow/tools_parser.go | 54 +++-- pkg/workflow/tools_types.go | 17 +- 8 files changed, 329 insertions(+), 86 deletions(-) diff --git a/.github/aw/syntax-agentic.md b/.github/aw/syntax-agentic.md index 7898c3d82e6..f7d5167e484 100644 --- a/.github/aw/syntax-agentic.md +++ b/.github/aw/syntax-agentic.md @@ -338,7 +338,13 @@ description: Agentic workflow specific frontmatter fields for GitHub Agentic Wor id: awf ``` - Sensitivity levels: `public` (no restrictions), `internal` (internal-only audiences), `confidential` (restricted within org), `sealed` (highest restriction). The staging credential used to access private repositories must remain host-side and is never written to the lock file or exposed to the agent. Use bounded queries when the question has a finite, bounded answer; prefer this over granting a cross-repository token or checking out the private repository into the primary workspace. + Sensitivity levels control how much information the agent may extract from the repository per run: + - `public`: unmetered disclosure budget; still operationally and schema-bounded, but no per-run cap on extracted bits. + - `internal`: 64 bits/run disclosure budget; use for repos with internal-audience content. + - `confidential`: 8 bits/run disclosure budget; use for restricted-within-org content. + - `sealed`: 0 bits/run; the query executes but cannot fund any answer — effectively a dry-run assertion. Do not use `sealed` when you need the agent to return information from the repository. + + The staging credential used to access private repositories must remain host-side and is never written to the lock file or exposed to the agent. Use bounded queries when the question has a finite, bounded answer; prefer this over granting a cross-repository token or checking out the private repository into the primary workspace. - **`safe-outputs:`** - Safe output processing configuration. See [safe-outputs.md](safe-outputs.md) for complete documentation of all output types: `create-issue`, `create-discussion`, `add-comment`, `create-pull-request`, `push-to-pull-request-branch`, `close-issue`, `close-discussion`, `update-issue`, `update-pull-request`, `add-labels`, `remove-labels`, `replace-label`, `dispatch-workflow`, `call-workflow`, `create-code-scanning-alert`, `upload-asset`, `upload-artifact`, `assign-to-agent`, `assign-to-user`, and more. diff --git a/pkg/parser/schemas/main_workflow_schema.json b/pkg/parser/schemas/main_workflow_schema.json index bc1532a773e..f6373f9e540 100644 --- a/pkg/parser/schemas/main_workflow_schema.json +++ b/pkg/parser/schemas/main_workflow_schema.json @@ -4281,12 +4281,13 @@ "timeout": { "type": "integer", "description": "Maximum execution time in seconds for a single bounded-query invocation. When omitted AWF uses its default.", - "minimum": 1 + "minimum": 1, + "maximum": 540 }, "memory-limit": { "type": "string", "description": "Memory limit for bounded-query container execution (e.g. \"512m\", \"2g\"). When omitted AWF uses its default.", - "pattern": "^\\d+[kmgKMG]$" + "pattern": "^[1-9][0-9]*[bkmgBKMG]$" }, "interpreter": { "type": "string", @@ -4296,7 +4297,8 @@ "max-invocations": { "type": "integer", "description": "Maximum number of bounded-query invocations allowed per run. When omitted AWF uses its default.", - "minimum": 1 + "minimum": 1, + "maximum": 10000 } } } @@ -13942,7 +13944,7 @@ }, "sessionId": { "type": "string", - "description": "Optional session identifier injected as the x-session-id request header and session_id body field on Copilot BYOK upstream requests. Maps to AWF_PROVIDER_SESSION_ID. Only set this field when your upstream supports it — strict OpenAI-compatible upstreams (e.g. Azure OpenAI) reject the unknown session_id body field with HTTP 400. Example: \"${{ github.run_id }}\"." + "description": "Optional session identifier injected as the x-session-id request header and session_id body field on Copilot BYOK upstream requests. Maps to AWF_PROVIDER_SESSION_ID. Only set this field when your upstream supports it \u2014 strict OpenAI-compatible upstreams (e.g. Azure OpenAI) reject the unknown session_id body field with HTTP 400. Example: \"${{ github.run_id }}\"." } }, "additionalProperties": false diff --git a/pkg/workflow/awf_config.go b/pkg/workflow/awf_config.go index 1ded66d874d..cbf18c0b026 100644 --- a/pkg/workflow/awf_config.go +++ b/pkg/workflow/awf_config.go @@ -974,12 +974,16 @@ func extractBoundedQueriesConfig(workflowData *WorkflowData) *AWFBoundedQueriesC } awfBQ := &AWFBoundedQueriesConfig{ - Enabled: true, - Runtime: bq.Runtime, - Timeout: bq.Timeout, - MemoryLimit: bq.MemoryLimit, - Interpreter: bq.Interpreter, - MaxInvocations: bq.MaxInvocations, + Enabled: true, + Runtime: bq.Runtime, + MemoryLimit: bq.MemoryLimit, + Interpreter: bq.Interpreter, + } + if bq.Timeout != nil { + awfBQ.Timeout = *bq.Timeout + } + if bq.MaxInvocations != nil { + awfBQ.MaxInvocations = *bq.MaxInvocations } for _, r := range bq.PrivateRepos { diff --git a/pkg/workflow/bounded_queries_test.go b/pkg/workflow/bounded_queries_test.go index 858b20d8fe4..7372023f5a5 100644 --- a/pkg/workflow/bounded_queries_test.go +++ b/pkg/workflow/bounded_queries_test.go @@ -84,10 +84,10 @@ func TestBuildAWFConfigJSON_BoundedQueries(t *testing.T) { {Repo: "my-org/internal-service", Sensitivity: "internal"}, }, Runtime: "docker", - Timeout: 30, + Timeout: new(30), MemoryLimit: "512m", Interpreter: "python3", - MaxInvocations: 32, + MaxInvocations: new(32), } config := makeBaseConfig(bq) config.WorkflowData.SandboxConfig.Agent.Version = string(constants.AWFBoundedQueriesMinVersion) @@ -188,10 +188,10 @@ func TestExtractBoundedQueriesConfig(t *testing.T) { {Repo: "my-org/confidential-service", Sensitivity: "confidential"}, }, Runtime: "docker", - Timeout: 30, + Timeout: new(30), MemoryLimit: "512m", Interpreter: "python3", - MaxInvocations: 32, + MaxInvocations: new(32), }, }, }, @@ -211,15 +211,39 @@ func TestExtractBoundedQueriesConfig(t *testing.T) { assert.Equal(t, "my-org/confidential-service", got.PrivateRepos[1].Repo) assert.Equal(t, "confidential", got.PrivateRepos[1].Sensitivity) }) + + t.Run("omits timeout and max-invocations when not set (nil pointers)", func(t *testing.T) { + data := &WorkflowData{ + ParsedTools: &ToolsConfig{ + GitHub: &GitHubToolConfig{ + BoundedQueries: &BoundedQueriesConfig{ + PrivateRepos: []*BoundedQueryPrivateRepo{ + {Repo: "my-org/internal-service", Sensitivity: "internal"}, + }, + // Timeout and MaxInvocations are nil — not set. + }, + }, + }, + } + + got := extractBoundedQueriesConfig(data) + require.NotNil(t, got) + assert.Equal(t, 0, got.Timeout, "timeout must be zero (omitted) when not set") + assert.Equal(t, 0, got.MaxInvocations, "max-invocations must be zero (omitted) when not set") + }) } // TestValidateBoundedQueriesConfig validates all validation rules for bounded queries. func TestValidateBoundedQueriesConfig(t *testing.T) { - // validAWFWorkflow returns a *WorkflowData with an AWF sandbox and the given bounded-queries config. + // validAWFWorkflow returns a *WorkflowData with an AWF sandbox pinned to the + // bounded-queries minimum version and the given bounded-queries config. validAWFWorkflow := func(bq *BoundedQueriesConfig) *WorkflowData { return &WorkflowData{ SandboxConfig: &SandboxConfig{ - Agent: &AgentSandboxConfig{ID: "awf"}, + Agent: &AgentSandboxConfig{ + ID: "awf", + Version: string(constants.AWFBoundedQueriesMinVersion), + }, }, ParsedTools: &ToolsConfig{ GitHub: &GitHubToolConfig{ @@ -244,10 +268,10 @@ func TestValidateBoundedQueriesConfig(t *testing.T) { {Repo: "my-org/my-repo", Sensitivity: "confidential"}, }, Runtime: "docker", - Timeout: 30, + Timeout: new(30), MemoryLimit: "512m", Interpreter: "python3", - MaxInvocations: 32, + MaxInvocations: new(32), }) assert.NoError(t, validateBoundedQueriesConfig(wd)) }) @@ -294,6 +318,48 @@ func TestValidateBoundedQueriesConfig(t *testing.T) { assert.Contains(t, err.Error(), "bounded-queries requires the AWF sandbox") }) + t.Run("rejects AWF version below minimum", func(t *testing.T) { + wd := &WorkflowData{ + SandboxConfig: &SandboxConfig{ + Agent: &AgentSandboxConfig{ + ID: "awf", + Version: "v0.27.42", // below v0.28.0 minimum + }, + }, + ParsedTools: &ToolsConfig{ + GitHub: &GitHubToolConfig{ + BoundedQueries: &BoundedQueriesConfig{ + PrivateRepos: []*BoundedQueryPrivateRepo{ + {Repo: "my-org/my-repo", Sensitivity: "internal"}, + }, + }, + }, + }, + } + err := validateBoundedQueriesConfig(wd) + require.Error(t, err) + assert.Contains(t, err.Error(), "bounded-queries requires AWF") + assert.Contains(t, err.Error(), string(constants.AWFBoundedQueriesMinVersion)) + }) + + t.Run("rejects malformed bounded-queries type", func(t *testing.T) { + wd := validAWFWorkflow(&BoundedQueriesConfig{ + ParseError: "bounded-queries must be a mapping object, got bool", + }) + err := validateBoundedQueriesConfig(wd) + require.Error(t, err) + assert.Contains(t, err.Error(), "bounded-queries must be a mapping object") + }) + + t.Run("rejects malformed private-repos type", func(t *testing.T) { + wd := validAWFWorkflow(&BoundedQueriesConfig{ + ParseError: "private-repos must be an array, got string", + }) + err := validateBoundedQueriesConfig(wd) + require.Error(t, err) + assert.Contains(t, err.Error(), "private-repos must be an array") + }) + t.Run("rejects empty private-repos", func(t *testing.T) { wd := validAWFWorkflow(&BoundedQueriesConfig{ PrivateRepos: []*BoundedQueryPrivateRepo{}, @@ -400,20 +466,64 @@ func TestValidateBoundedQueriesConfig(t *testing.T) { assert.NoError(t, validateBoundedQueriesConfig(wd)) }) - t.Run("rejects negative timeout", func(t *testing.T) { - wd := validAWFWorkflow(&BoundedQueriesConfig{ - PrivateRepos: []*BoundedQueryPrivateRepo{ - {Repo: "my-org/my-repo", Sensitivity: "internal"}, - }, - Timeout: -1, - }) - err := validateBoundedQueriesConfig(wd) - require.Error(t, err) - assert.Contains(t, err.Error(), "timeout must be a positive integer") + t.Run("accepts timeout at boundary values", func(t *testing.T) { + for _, v := range []int{1, 270, 540} { + wd := validAWFWorkflow(&BoundedQueriesConfig{ + PrivateRepos: []*BoundedQueryPrivateRepo{ + {Repo: "my-org/my-repo", Sensitivity: "internal"}, + }, + Timeout: new(v), + }) + assert.NoError(t, validateBoundedQueriesConfig(wd)) + } + }) + + t.Run("rejects timeout out of range", func(t *testing.T) { + for _, v := range []int{-1, 0, 541, 9999} { + t.Run("timeout "+string(rune('0'+v%10)), func(t *testing.T) { + wd := validAWFWorkflow(&BoundedQueriesConfig{ + PrivateRepos: []*BoundedQueryPrivateRepo{ + {Repo: "my-org/my-repo", Sensitivity: "internal"}, + }, + Timeout: new(v), + }) + err := validateBoundedQueriesConfig(wd) + require.Error(t, err) + assert.Contains(t, err.Error(), "timeout") + }) + } + }) + + t.Run("accepts max-invocations at boundary values", func(t *testing.T) { + for _, v := range []int{1, 5000, 10000} { + wd := validAWFWorkflow(&BoundedQueriesConfig{ + PrivateRepos: []*BoundedQueryPrivateRepo{ + {Repo: "my-org/my-repo", Sensitivity: "internal"}, + }, + MaxInvocations: new(v), + }) + assert.NoError(t, validateBoundedQueriesConfig(wd)) + } + }) + + t.Run("rejects max-invocations out of range", func(t *testing.T) { + for _, v := range []int{-1, 0, 10001, 99999} { + t.Run("max-invocations "+string(rune('0'+v%10)), func(t *testing.T) { + wd := validAWFWorkflow(&BoundedQueriesConfig{ + PrivateRepos: []*BoundedQueryPrivateRepo{ + {Repo: "my-org/my-repo", Sensitivity: "internal"}, + }, + MaxInvocations: new(v), + }) + err := validateBoundedQueriesConfig(wd) + require.Error(t, err) + assert.Contains(t, err.Error(), "max-invocations") + }) + } }) t.Run("rejects invalid memory-limit format", func(t *testing.T) { - for _, invalid := range []string{"512", "512mb", "5.5g", "abc"} { + for _, invalid := range []string{"512", "512mb", "5.5g", "abc", "0m", "0k", "00512m"} { t.Run(invalid, func(t *testing.T) { wd := validAWFWorkflow(&BoundedQueriesConfig{ PrivateRepos: []*BoundedQueryPrivateRepo{ @@ -429,7 +539,7 @@ func TestValidateBoundedQueriesConfig(t *testing.T) { }) t.Run("accepts valid memory-limit formats", func(t *testing.T) { - for _, valid := range []string{"512m", "2g", "1024k", "512M", "2G"} { + for _, valid := range []string{"1b", "512m", "2g", "1024k", "512M", "2G", "1B", "1K"} { t.Run(valid, func(t *testing.T) { wd := validAWFWorkflow(&BoundedQueriesConfig{ PrivateRepos: []*BoundedQueryPrivateRepo{ @@ -453,18 +563,6 @@ func TestValidateBoundedQueriesConfig(t *testing.T) { require.Error(t, err) assert.Contains(t, err.Error(), "unsupported bounded-queries interpreter") }) - - t.Run("rejects negative max-invocations", func(t *testing.T) { - wd := validAWFWorkflow(&BoundedQueriesConfig{ - PrivateRepos: []*BoundedQueryPrivateRepo{ - {Repo: "my-org/my-repo", Sensitivity: "internal"}, - }, - MaxInvocations: -1, - }) - err := validateBoundedQueriesConfig(wd) - require.Error(t, err) - assert.Contains(t, err.Error(), "max-invocations must be a positive integer") - }) } // TestValidateRepoSlug covers edge cases for the repo-slug validator. @@ -538,3 +636,64 @@ func TestAWFBoundedQueriesJSONRoundtrip(t *testing.T) { assert.Equal(t, "my-org/sealed-service", got.PrivateRepos[3].Repo) assert.Equal(t, "sealed", got.PrivateRepos[3].Sensitivity) } + +// TestParseBoundedQueriesConfig_MalformedInput verifies that parse errors are surfaced +// via ParseError rather than silently discarded. +func TestParseBoundedQueriesConfig_MalformedInput(t *testing.T) { + t.Run("wrong type for bounded-queries (bool) sets ParseError", func(t *testing.T) { + result := parseBoundedQueriesConfig(map[string]any{ + // parseBoundedQueriesConfig receives only the inner map; the type check + // for the bounded-queries block itself is in parseGitHubTool. + }) + // Empty map: no ParseError, just an empty config. + assert.Empty(t, result.ParseError) + }) + + t.Run("wrong type for private-repos (string) sets ParseError", func(t *testing.T) { + result := parseBoundedQueriesConfig(map[string]any{ + "private-repos": "not-an-array", + }) + require.NotEmpty(t, result.ParseError) + assert.Contains(t, result.ParseError, "private-repos must be an array") + }) + + t.Run("non-map item in private-repos sets ParseError", func(t *testing.T) { + result := parseBoundedQueriesConfig(map[string]any{ + "private-repos": []any{"string-not-a-map"}, + }) + require.NotEmpty(t, result.ParseError) + assert.Contains(t, result.ParseError, "private-repos[0] must be a mapping object") + }) + + t.Run("wrong type for timeout sets ParseError", func(t *testing.T) { + result := parseBoundedQueriesConfig(map[string]any{ + "timeout": "thirty", + }) + require.NotEmpty(t, result.ParseError) + assert.Contains(t, result.ParseError, "timeout must be an integer") + }) + + t.Run("wrong type for max-invocations sets ParseError", func(t *testing.T) { + result := parseBoundedQueriesConfig(map[string]any{ + "max-invocations": true, + }) + require.NotEmpty(t, result.ParseError) + assert.Contains(t, result.ParseError, "max-invocations must be an integer") + }) + + t.Run("valid map returns no ParseError", func(t *testing.T) { + result := parseBoundedQueriesConfig(map[string]any{ + "private-repos": []any{ + map[string]any{"repo": "my-org/my-repo", "sensitivity": "internal"}, + }, + "timeout": 30, + "max-invocations": 5, + }) + assert.Empty(t, result.ParseError) + require.Len(t, result.PrivateRepos, 1) + require.NotNil(t, result.Timeout) + assert.Equal(t, 30, *result.Timeout) + require.NotNil(t, result.MaxInvocations) + assert.Equal(t, 5, *result.MaxInvocations) + }) +} diff --git a/pkg/workflow/sandbox_validation.go b/pkg/workflow/sandbox_validation.go index 75221827013..ab14fec296d 100644 --- a/pkg/workflow/sandbox_validation.go +++ b/pkg/workflow/sandbox_validation.go @@ -243,6 +243,16 @@ func validateBoundedQueriesConfig(workflowData *WorkflowData) error { return nil } + // Reject malformed frontmatter that the parser recorded as a type error. + if bq.ParseError != "" { + return NewValidationError( + "tools.github.bounded-queries", + "", + bq.ParseError, + "Ensure bounded-queries is a valid mapping object:\n\ntools:\n github:\n bounded-queries:\n private-repos:\n - repo: my-org/my-repo\n sensitivity: internal\n\nSee: "+string(constants.DocsSandboxURL), + ) + } + // bounded-queries is only supported for the AWF sandbox. var agentType SandboxType if workflowData.SandboxConfig != nil && workflowData.SandboxConfig.Agent != nil { @@ -257,6 +267,27 @@ func validateBoundedQueriesConfig(workflowData *WorkflowData) error { ) } + // Verify that the effective AWF version supports bounded queries. + // Fail early with a clear message rather than silently generating a workflow + // that lacks the requested capability and may follow an invalid access model. + if !awfSupportsBoundedQueries(getFirewallConfig(workflowData)) { + firewallConfig := getFirewallConfig(workflowData) + var configuredVersion string + if firewallConfig != nil { + configuredVersion = firewallConfig.Version + } + effectiveVersion := configuredVersion + if effectiveVersion == "" { + effectiveVersion = string(constants.DefaultFirewallVersion) + } + return NewValidationError( + "tools.github.bounded-queries", + effectiveVersion, + fmt.Sprintf("bounded-queries requires AWF %s or newer", constants.AWFBoundedQueriesMinVersion), + fmt.Sprintf("bounded-queries is only supported in AWF %s+.\n\nThe effective AWF version is %s. Set firewall.version or sandbox.agent.version to %s or newer.", constants.AWFBoundedQueriesMinVersion, effectiveVersion, constants.AWFBoundedQueriesMinVersion), + ) + } + // Validate that private-repos is non-empty. if len(bq.PrivateRepos) == 0 { return NewValidationError( @@ -317,14 +348,16 @@ func validateBoundedQueriesConfig(workflowData *WorkflowData) error { } } - // Validate optional timeout. - if bq.Timeout < 0 { - return NewValidationError( - "tools.github.bounded-queries.timeout", - strconv.Itoa(bq.Timeout), - "bounded-queries timeout must be a positive integer", - fmt.Sprintf("Set timeout to a positive number of seconds.\n\nSee: %s", constants.DocsSandboxURL), - ) + // Validate optional timeout (1–540 seconds; explicit zero is also rejected). + if bq.Timeout != nil { + if err := validateIntRange(*bq.Timeout, 1, 540, "tools.github.bounded-queries.timeout"); err != nil { + return NewValidationError( + "tools.github.bounded-queries.timeout", + strconv.Itoa(*bq.Timeout), + "bounded-queries timeout must be between 1 and 540 seconds", + fmt.Sprintf("Set timeout to a value between 1 and 540 (seconds).\n\nSee: %s", constants.DocsSandboxURL), + ) + } } // Validate optional memory-limit format (e.g. "512m", "2g"). @@ -346,14 +379,16 @@ func validateBoundedQueriesConfig(workflowData *WorkflowData) error { } } - // Validate optional max-invocations. - if bq.MaxInvocations < 0 { - return NewValidationError( - "tools.github.bounded-queries.max-invocations", - strconv.Itoa(bq.MaxInvocations), - "bounded-queries max-invocations must be a positive integer", - fmt.Sprintf("Set max-invocations to a positive integer.\n\nSee: %s", constants.DocsSandboxURL), - ) + // Validate optional max-invocations (1–10000; explicit zero is also rejected). + if bq.MaxInvocations != nil { + if err := validateIntRange(*bq.MaxInvocations, 1, 10000, "tools.github.bounded-queries.max-invocations"); err != nil { + return NewValidationError( + "tools.github.bounded-queries.max-invocations", + strconv.Itoa(*bq.MaxInvocations), + "bounded-queries max-invocations must be between 1 and 10000", + fmt.Sprintf("Set max-invocations to a value between 1 and 10000.\n\nSee: %s", constants.DocsSandboxURL), + ) + } } sandboxValidationLog.Printf("bounded-queries validation passed: %d private repo(s)", len(bq.PrivateRepos)) @@ -390,8 +425,12 @@ func validateRepoSlug(field, slug string) error { return nil } -// memoryLimitPattern matches valid memory limit strings (e.g. "512m", "2g", "1024k"). -var memoryLimitPattern = regexp.MustCompile(`^\d+[kmgKMG]$`) +// memoryLimitPattern matches valid memory limit strings. +// The value must start with a non-zero digit, optionally followed by more digits, +// and end with one of: b, k, m, g (case-insensitive). Leading zeros and bare-zero +// values (e.g. "0m") are rejected because AWF rejects them at startup. +// Examples of valid values: "512m", "2g", "1024k", "1b", "128M". +var memoryLimitPattern = regexp.MustCompile(`^[1-9][0-9]*[bkmgBKMG]$`) // validateBoundedQueryMemoryLimit checks that a memory-limit string has the correct format. func validateBoundedQueryMemoryLimit(memoryLimit string) error { @@ -399,8 +438,8 @@ func validateBoundedQueryMemoryLimit(memoryLimit string) error { return NewValidationError( "tools.github.bounded-queries.memory-limit", memoryLimit, - "memory-limit must be a number followed by a unit: k, m, or g (e.g. \"512m\", \"2g\")", - fmt.Sprintf("Use a valid memory limit format:\n\ntools:\n github:\n bounded-queries:\n memory-limit: 512m # examples: 512m, 2g, 1024k\n\nSee: %s", constants.DocsSandboxURL), + "memory-limit must be a positive number followed by a unit: b, k, m, or g (e.g. \"512m\", \"2g\")", + fmt.Sprintf("Use a valid memory limit format:\n\ntools:\n github:\n bounded-queries:\n memory-limit: 512m # examples: 512m, 2g, 1024k, 1b\n\nSee: %s", constants.DocsSandboxURL), ) } return nil diff --git a/pkg/workflow/schemas/awf-config.schema.json b/pkg/workflow/schemas/awf-config.schema.json index 7582ea7c463..3e8ccecb2ec 100644 --- a/pkg/workflow/schemas/awf-config.schema.json +++ b/pkg/workflow/schemas/awf-config.schema.json @@ -807,12 +807,13 @@ "timeout": { "type": "integer", "description": "Maximum execution time in seconds for a single bounded-query invocation. When omitted AWF uses its default.", - "minimum": 1 + "minimum": 1, + "maximum": 540 }, "memoryLimit": { "type": "string", "description": "Memory limit for bounded-query container execution (e.g. \"512m\", \"2g\"). When omitted AWF uses its default.", - "pattern": "^\\d+[kmgKMG]$" + "pattern": "^[1-9][0-9]*[bkmgBKMG]$" }, "interpreter": { "type": "string", @@ -822,7 +823,8 @@ "maxInvocations": { "type": "integer", "description": "Maximum number of bounded-query invocations allowed per run. When omitted AWF uses its default.", - "minimum": 1 + "minimum": 1, + "maximum": 10000 } } } diff --git a/pkg/workflow/tools_parser.go b/pkg/workflow/tools_parser.go index 2801e47e845..96650d7e0a9 100644 --- a/pkg/workflow/tools_parser.go +++ b/pkg/workflow/tools_parser.go @@ -416,6 +416,11 @@ func parseGitHubTool(val any) *GitHubToolConfig { if rawBQ, ok := configMap["bounded-queries"]; ok { if bqMap, ok := rawBQ.(map[string]any); ok { config.BoundedQueries = parseBoundedQueriesConfig(bqMap) + } else { + // Wrong type — create a sentinel so the validator can emit a proper error. + config.BoundedQueries = &BoundedQueriesConfig{ + ParseError: fmt.Sprintf("bounded-queries must be a mapping object, got %T", rawBQ), + } } } @@ -431,27 +436,41 @@ func parseGitHubTool(val any) *GitHubToolConfig { func parseBoundedQueriesConfig(bqMap map[string]any) *BoundedQueriesConfig { config := &BoundedQueriesConfig{} - if rawRepos, ok := bqMap["private-repos"].([]any); ok { - config.PrivateRepos = make([]*BoundedQueryPrivateRepo, 0, len(rawRepos)) - for _, item := range rawRepos { - if repoMap, ok := item.(map[string]any); ok { - entry := &BoundedQueryPrivateRepo{} - if repo, ok := repoMap["repo"].(string); ok { - entry.Repo = repo - } - if sensitivity, ok := repoMap["sensitivity"].(string); ok { - entry.Sensitivity = sensitivity + if rawRepos, ok := bqMap["private-repos"]; ok { + switch repos := rawRepos.(type) { + case []any: + config.PrivateRepos = make([]*BoundedQueryPrivateRepo, 0, len(repos)) + for i, item := range repos { + if repoMap, ok := item.(map[string]any); ok { + entry := &BoundedQueryPrivateRepo{} + if repo, ok := repoMap["repo"].(string); ok { + entry.Repo = repo + } + if sensitivity, ok := repoMap["sensitivity"].(string); ok { + entry.Sensitivity = sensitivity + } + config.PrivateRepos = append(config.PrivateRepos, entry) + } else { + config.ParseError = fmt.Sprintf("private-repos[%d] must be a mapping object, got %T", i, item) + return config } - config.PrivateRepos = append(config.PrivateRepos, entry) } + default: + config.ParseError = fmt.Sprintf("private-repos must be an array, got %T", rawRepos) + return config } } if runtime, ok := bqMap["runtime"].(string); ok { config.Runtime = runtime } - if timeout, ok := bqMap["timeout"].(int); ok { - config.Timeout = timeout + if rawTimeout, hasTimeout := bqMap["timeout"]; hasTimeout { + if timeout, ok := rawTimeout.(int); ok { + config.Timeout = &timeout + } else { + config.ParseError = fmt.Sprintf("timeout must be an integer, got %T", rawTimeout) + return config + } } if memoryLimit, ok := bqMap["memory-limit"].(string); ok { config.MemoryLimit = memoryLimit @@ -459,8 +478,13 @@ func parseBoundedQueriesConfig(bqMap map[string]any) *BoundedQueriesConfig { if interpreter, ok := bqMap["interpreter"].(string); ok { config.Interpreter = interpreter } - if maxInvocations, ok := bqMap["max-invocations"].(int); ok { - config.MaxInvocations = maxInvocations + if rawMax, hasMax := bqMap["max-invocations"]; hasMax { + if maxInvocations, ok := rawMax.(int); ok { + config.MaxInvocations = &maxInvocations + } else { + config.ParseError = fmt.Sprintf("max-invocations must be an integer, got %T", rawMax) + return config + } } return config diff --git a/pkg/workflow/tools_types.go b/pkg/workflow/tools_types.go index 2c1fd54be51..c8ad9225de0 100644 --- a/pkg/workflow/tools_types.go +++ b/pkg/workflow/tools_types.go @@ -406,13 +406,14 @@ type BoundedQueriesConfig struct { // Runtime is the container runtime used to execute bounded-query scripts. // Optional; when omitted AWF uses its default runtime. - // Supported values: "docker" + // Supported values: "docker", "gvisor" Runtime string `yaml:"runtime,omitempty"` // Timeout is the maximum execution time in seconds for a single bounded-query invocation. // Optional; when omitted AWF uses its default timeout. - // Must be a positive integer. - Timeout int `yaml:"timeout,omitempty"` + // Must be a positive integer in the range 1–540. + // A pointer distinguishes "not set" (nil) from an explicitly set zero, which is rejected. + Timeout *int `yaml:"timeout,omitempty"` // MemoryLimit is the memory limit for bounded-query container execution (e.g. "512m", "1g"). // Optional; when omitted AWF uses its default memory limit. @@ -424,8 +425,14 @@ type BoundedQueriesConfig struct { // MaxInvocations is the maximum number of bounded-query invocations allowed per run. // Optional; when omitted AWF uses its default. - // Must be a positive integer. - MaxInvocations int `yaml:"max-invocations,omitempty"` + // Must be a positive integer in the range 1–10000. + // A pointer distinguishes "not set" (nil) from an explicitly set zero, which is rejected. + MaxInvocations *int `yaml:"max-invocations,omitempty"` + + // ParseError records a type mismatch or structural error encountered during YAML parsing. + // Non-empty when bounded-queries or private-repos had an unexpected type in the frontmatter. + // The compiler treats a non-empty ParseError as a hard validation error. + ParseError string `yaml:"-"` } // BoundedQueryPrivateRepo describes one private repository approved for bounded-query access.