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 09d9a38c832..f7d5167e484 100644 --- a/.github/aw/syntax-agentic.md +++ b/.github/aw/syntax-agentic.md @@ -317,6 +317,34 @@ description: Agentic workflow specific frontmatter fields for GitHub Agentic Wor - **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 + tools: + github: + 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; 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 + max-invocations: 32 # optional; default: AWF default + sandbox: + agent: + id: awf + ``` + + 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/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/parser/schemas/main_workflow_schema.json b/pkg/parser/schemas/main_workflow_schema.json index 679c31c4013..f6373f9e540 100644 --- a/pkg/parser/schemas/main_workflow_schema.json +++ b/pkg/parser/schemas/main_workflow_schema.json @@ -4243,6 +4243,64 @@ "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, + "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": "^[1-9][0-9]*[bkmgBKMG]$" + }, + "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, + "maximum": 10000 + } + } } }, "additionalProperties": false, @@ -13886,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 1d6937fbe68..cbf18c0b026 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,45 @@ func extractModelCostProviders(workflowData *WorkflowData) map[string]any { return clone } +// extractBoundedQueriesConfig returns an AWFBoundedQueriesConfig populated from +// 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.ParsedTools == nil || workflowData.ParsedTools.GitHub == nil { + return nil + } + bq := workflowData.ParsedTools.GitHub.BoundedQueries + if bq == nil { + return nil + } + + awfBQ := &AWFBoundedQueriesConfig{ + 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 { + 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..7372023f5a5 --- /dev/null +++ b/pkg/workflow/bounded_queries_test.go @@ -0,0 +1,699 @@ +//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", + }, + }, + ParsedTools: &ToolsConfig{ + GitHub: &GitHubToolConfig{ + 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: new(30), + MemoryLimit: "512m", + Interpreter: "python3", + MaxInvocations: new(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 ParsedTools", func(t *testing.T) { + assert.Nil(t, extractBoundedQueriesConfig(&WorkflowData{})) + }) + + t.Run("returns nil for missing GitHub tool config", func(t *testing.T) { + assert.Nil(t, extractBoundedQueriesConfig(&WorkflowData{ + ParsedTools: &ToolsConfig{}, + })) + }) + + t.Run("returns nil when bounded-queries is absent", func(t *testing.T) { + assert.Nil(t, extractBoundedQueriesConfig(&WorkflowData{ + ParsedTools: &ToolsConfig{ + GitHub: &GitHubToolConfig{}, + }, + })) + }) + + t.Run("maps all fields correctly", func(t *testing.T) { + data := &WorkflowData{ + ParsedTools: &ToolsConfig{ + GitHub: &GitHubToolConfig{ + BoundedQueries: &BoundedQueriesConfig{ + PrivateRepos: []*BoundedQueryPrivateRepo{ + {Repo: "my-org/internal-service", Sensitivity: "internal"}, + {Repo: "my-org/confidential-service", Sensitivity: "confidential"}, + }, + Runtime: "docker", + Timeout: new(30), + MemoryLimit: "512m", + Interpreter: "python3", + MaxInvocations: new(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) + }) + + 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 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", + Version: string(constants.AWFBoundedQueriesMinVersion), + }, + }, + ParsedTools: &ToolsConfig{ + GitHub: &GitHubToolConfig{ + BoundedQueries: bq, + }, + }, + } + } + + t.Run("valid minimal config passes", func(t *testing.T) { + wd := validAWFWorkflow(&BoundedQueriesConfig{ + PrivateRepos: []*BoundedQueryPrivateRepo{ + {Repo: "my-org/my-repo", Sensitivity: "internal"}, + }, + }) + assert.NoError(t, validateBoundedQueriesConfig(wd)) + }) + + t.Run("valid config with all optional fields passes", func(t *testing.T) { + wd := validAWFWorkflow(&BoundedQueriesConfig{ + PrivateRepos: []*BoundedQueryPrivateRepo{ + {Repo: "my-org/my-repo", Sensitivity: "confidential"}, + }, + Runtime: "docker", + Timeout: new(30), + MemoryLimit: "512m", + Interpreter: "python3", + MaxInvocations: new(32), + }) + 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) { + wd := validAWFWorkflow(&BoundedQueriesConfig{ + PrivateRepos: []*BoundedQueryPrivateRepo{ + {Repo: "my-org/my-repo", Sensitivity: sensitivity}, + }, + }) + assert.NoError(t, validateBoundedQueriesConfig(wd)) + }) + } + }) + + t.Run("nil WorkflowData returns nil", func(t *testing.T) { + assert.NoError(t, validateBoundedQueriesConfig(nil)) + }) + + 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) { + 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(wd) + require.Error(t, err) + 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{}, + }) + 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) { + 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) { + wd := validAWFWorkflow(&BoundedQueriesConfig{ + PrivateRepos: []*BoundedQueryPrivateRepo{ + {Repo: "my-org/my-repo", Sensitivity: "top-secret"}, + }, + }) + 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) { + 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 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{ + {Repo: "${{ inputs.repo }}", Sensitivity: "internal"}, + }, + }) + 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) { + wd := validAWFWorkflow(&BoundedQueriesConfig{ + PrivateRepos: []*BoundedQueryPrivateRepo{ + {Repo: "myrepo", Sensitivity: "internal"}, + }, + }) + 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) { + wd := validAWFWorkflow(&BoundedQueriesConfig{ + PrivateRepos: []*BoundedQueryPrivateRepo{ + {Repo: "", Sensitivity: "internal"}, + }, + }) + 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) { + wd := validAWFWorkflow(&BoundedQueriesConfig{ + PrivateRepos: []*BoundedQueryPrivateRepo{ + {Repo: "my-org/my-repo", Sensitivity: "internal"}, + }, + Runtime: "podman", + }) + err := validateBoundedQueriesConfig(wd) + require.Error(t, err) + 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("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", "0m", "0k", "00512m"} { + t.Run(invalid, func(t *testing.T) { + wd := validAWFWorkflow(&BoundedQueriesConfig{ + PrivateRepos: []*BoundedQueryPrivateRepo{ + {Repo: "my-org/my-repo", Sensitivity: "internal"}, + }, + MemoryLimit: invalid, + }) + err := validateBoundedQueriesConfig(wd) + 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{"1b", "512m", "2g", "1024k", "512M", "2G", "1B", "1K"} { + t.Run(valid, func(t *testing.T) { + wd := validAWFWorkflow(&BoundedQueriesConfig{ + PrivateRepos: []*BoundedQueryPrivateRepo{ + {Repo: "my-org/my-repo", Sensitivity: "internal"}, + }, + MemoryLimit: valid, + }) + assert.NoError(t, validateBoundedQueriesConfig(wd)) + }) + } + }) + + t.Run("rejects unsupported interpreter", func(t *testing.T) { + wd := validAWFWorkflow(&BoundedQueriesConfig{ + PrivateRepos: []*BoundedQueryPrivateRepo{ + {Repo: "my-org/my-repo", Sensitivity: "internal"}, + }, + Interpreter: "ruby", + }) + err := validateBoundedQueriesConfig(wd) + require.Error(t, err) + assert.Contains(t, err.Error(), "unsupported bounded-queries interpreter") + }) +} + +// 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) +} + +// 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/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_validation.go b/pkg/workflow/sandbox_validation.go index 804590a2fa5..ab14fec296d 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 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. @@ -14,6 +15,7 @@ import ( "errors" "fmt" "regexp" + "strconv" "strings" "github.com/github/gh-aw/pkg/constants" @@ -211,6 +213,238 @@ func validateSandboxConfig(workflowData *WorkflowData) error { 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": {}, + "gvisor": {}, +} + +// validBoundedQueryInterpreters is the set of accepted script interpreters. +var validBoundedQueryInterpreters = map[string]struct{}{ + "python3": {}, +} + +// validateBoundedQueriesConfig validates tools.github.bounded-queries configuration. +// Returns an error when the configuration is invalid. +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 + } + + // 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 { + agentType = getAgentType(workflowData.SandboxConfig.Agent) + } + if !isSupportedSandboxType(agentType) { + return NewValidationError( + "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\ntools:\n github:\n bounded-queries:\n private-repos:\n - repo: my-org/my-repo\n sensitivity: internal\n\nSee: "+string(constants.DocsSandboxURL), + ) + } + + // 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( + "tools.github.bounded-queries.private-repos", + "[]", + "bounded-queries requires at least one private-repos entry", + "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("tools.github.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\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), + ) + } + + // 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", + 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( + "tools.github.bounded-queries.runtime", + bq.Runtime, + "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), + ) + } + } + + // 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"). + 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( + "tools.github.bounded-queries.interpreter", + bq.Interpreter, + "unsupported bounded-queries interpreter: must be \"python3\"", + fmt.Sprintf("Set interpreter to a supported value:\n\ntools:\n github:\n bounded-queries:\n interpreter: python3\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)) + 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. +// 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 { + if !memoryLimitPattern.MatchString(memoryLimit) { + return NewValidationError( + "tools.github.bounded-queries.memory-limit", + memoryLimit, + "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 +} + func getSandboxDisableJustification(workflowData *WorkflowData) (string, error) { if workflowData == nil || workflowData.Features == nil { return "", errors.New("dangerously-disable-sandbox-agent feature is missing") diff --git a/pkg/workflow/schemas/awf-config.schema.json b/pkg/workflow/schemas/awf-config.schema.json index df1258f28cf..3e8ccecb2ec 100644 --- a/pkg/workflow/schemas/awf-config.schema.json +++ b/pkg/workflow/schemas/awf-config.schema.json @@ -766,6 +766,67 @@ "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", "gvisor"] + }, + "timeout": { + "type": "integer", + "description": "Maximum execution time in seconds for a single bounded-query invocation. When omitted AWF uses its default.", + "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": "^[1-9][0-9]*[bkmgBKMG]$" + }, + "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, + "maximum": 10000 + } + } } }, "$defs": { diff --git a/pkg/workflow/tools_parser.go b/pkg/workflow/tools_parser.go index a4dc540ea83..96650d7e0a9 100644 --- a/pkg/workflow/tools_parser.go +++ b/pkg/workflow/tools_parser.go @@ -412,6 +412,18 @@ 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) + } 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), + } + } + } + return config } @@ -420,7 +432,63 @@ 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"]; 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 + } + } + 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 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 + } + if interpreter, ok := bqMap["interpreter"].(string); ok { + config.Interpreter = interpreter + } + 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 +} 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..c8ad9225de0 100644 --- a/pkg/workflow/tools_types.go +++ b/pkg/workflow/tools_types.go @@ -373,6 +373,77 @@ 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", "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 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. + 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 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. +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