From c25b0a9790a32c7f710c0cd1a6f9d6eea73fc2d8 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 10 Sep 2026 04:34:25 +0000 Subject: [PATCH 01/10] Initial plan From 804c6e4a2f8a73504dbb1af76ffa4c3336d159dd Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 10 Sep 2026 04:55:36 +0000 Subject: [PATCH 02/10] Fix static enclave GitHub startup path Co-authored-by: lpcox <15877973+lpcox@users.noreply.github.com> --- .../fix-enclave-only-github-guard-policy.md | 2 +- actions/setup/js/mcp_cli_bridge.cjs | 73 +++++++++++++++++- actions/setup/js/mcp_cli_bridge.test.cjs | 74 +++++++++++++++++++ actions/setup/sh/check_mcp_servers.sh | 3 + actions/setup/sh/check_mcp_servers_test.sh | 54 ++++++++++++++ pkg/workflow/enclave_github_proxy_test.go | 7 ++ pkg/workflow/mcp_cli_mount.go | 42 ++++++++++- pkg/workflow/mcp_cli_mount_test.go | 17 +++++ pkg/workflow/mcp_github_config.go | 32 ++++++++ pkg/workflow/mcp_setup_gateway.go | 3 + 10 files changed, 301 insertions(+), 6 deletions(-) diff --git a/.changeset/fix-enclave-only-github-guard-policy.md b/.changeset/fix-enclave-only-github-guard-policy.md index 12d33f7e0ab..892cac41723 100644 --- a/.changeset/fix-enclave-only-github-guard-policy.md +++ b/.changeset/fix-enclave-only-github-guard-policy.md @@ -2,4 +2,4 @@ "gh-aw": patch --- -Fix an invalid empty server-level GitHub guard policy when GitHub tools are enabled only for an enclave agent (`tools.github: false` with `enclaves[].agent.tools.github`). The generated MCP Gateway configuration referenced `determine-automatic-lockdown` step outputs even though that step is not generated, so both guard values expanded to empty strings and mcpg exited during startup with `min-integrity is required`. The GitHub server now gets a server-level guard policy mirroring the enclave identity policy, and the lockdown step outputs are only referenced when the step is generated. +Fix static GitHub agent enclaves when primary GitHub tools are disabled (`tools.github: false` with `enclaves[].agent.tools.github`). The compiler now emits the enclave-scoped GitHub guard policy together with the matching safeoutputs `write-sink` policy, probes enclave-only GitHub with the enclave identity, refreshes deferred `awf-enclave` CLI tool schemas after late backend registration, and documents finite-disclosure response-schema bit budgets in the generated prompt. diff --git a/actions/setup/js/mcp_cli_bridge.cjs b/actions/setup/js/mcp_cli_bridge.cjs index 6f363fcdb11..0a70a2207c6 100644 --- a/actions/setup/js/mcp_cli_bridge.cjs +++ b/actions/setup/js/mcp_cli_bridge.cjs @@ -74,6 +74,7 @@ const TOOL_HELP_MAX_LINES = 30; const TOOL_DESC_MAX_LEN = 90; const COMPACT_NAME_LINE_TARGET_WIDTH = 110; const SAFEOUTPUTS_SERVER_NAME = "safeoutputs"; +const DEFERRED_SERVERS_ENV = "GH_AW_MCP_DEFERRED_SERVERS"; // --------------------------------------------------------------------------- // Audit logging @@ -1110,6 +1111,62 @@ function ensureSafeOutputsTools(tools, serverName, toolsFile) { throw new Error(`[${serverName}] tool schema is empty (${toolsFile}). ` + "Failing fast to prevent runs where safe-output tools cannot be discovered."); } +/** + * @param {string} name + * @param {string} list + * @returns {boolean} + */ +function serverInCommaList(name, list) { + return list + .split(",") + .map(item => item.trim()) + .filter(Boolean) + .includes(name); +} + +/** + * Fetch the live tools/list result for a server and persist it over an empty + * cache. Deferred servers such as awf-enclave may register after the wrapper is + * mounted, so their startup-time cache can legitimately be empty. + * + * @param {Array<{name: string, description?: string, inputSchema?: {properties?: Record, required?: string[]}}>} tools + * @param {string} serverName + * @param {string} serverUrl + * @param {string} apiKey + * @param {string} toolsFile + * @returns {Promise, required?: string[]}}>>} + */ +async function refreshDeferredToolsIfNeeded(tools, serverName, serverUrl, apiKey, toolsFile) { + if (tools.length > 0 || !serverInCommaList(serverName, process.env[DEFERRED_SERVERS_ENV] || "")) { + return tools; + } + const core = global.core; + core.warning(`[${serverName}] cached tool schema is empty for deferred server; refreshing from live gateway`); + const sessionId = await mcpInitialize(serverUrl, apiKey, serverName); + await mcpNotifyInitialized(serverUrl, apiKey, sessionId, serverName); + /** @type {Record} */ + const headers = { Authorization: apiKey }; + if (sessionId) { + headers["Mcp-Session-Id"] = sessionId; + } + const resp = await httpPostJSON(serverUrl, headers, { jsonrpc: "2.0", id: 3, method: "tools/list" }, DEFAULT_HTTP_TIMEOUT_MS); + const messages = extractJSONRPCMessages(resp.body); + const resultMessage = messages.find(isResultMessage); + const result = resultMessage && typeof resultMessage === "object" && "result" in resultMessage && resultMessage.result && typeof resultMessage.result === "object" ? resultMessage.result : null; + const refreshed = result && "tools" in result && Array.isArray(result.tools) ? result.tools : []; + if (refreshed.length === 0) { + core.warning(`[${serverName}] live tools/list still returned 0 tools for deferred server`); + return tools; + } + try { + fs.writeFileSync(toolsFile, JSON.stringify(refreshed, null, 2), { mode: 0o644 }); + } catch (err) { + core.warning(`[${serverName}] failed to update refreshed tools cache ${toolsFile}: ${getErrorMessage(err)}`); + } + core.info(`[${serverName}] refreshed deferred tools cache with ${refreshed.length} tool(s)`); + return refreshed; +} + /** * Show top-level help: list all available commands for a server. * @@ -1387,10 +1444,13 @@ async function formatResponse(responseBody, serverName, toolName = "") { const message = "message" in errRecord ? String(errRecord.message || "Unknown error") : "Unknown error"; const code = "code" in errRecord && errRecord.code != null ? String(errRecord.code) : ""; const isSafeOutputsEmptyArgs = serverName === SAFEOUTPUTS_SERVER_NAME && code === "-32602" && /Empty arguments are not allowed/i.test(message); + const isEnclaveBitBudget = /bit-budget-exhausted/i.test(message); const hint = isSafeOutputsEmptyArgs && toolName ? `Hint: do not retry '${serverName} ${toolName}' with empty arguments. Run '${serverName} ${toolName} --help' to inspect the required options, or call 'noop' with a message if no action is needed.` - : ""; + : isEnclaveBitBudget + ? "Hint: the enclave response schema exceeded the finite-disclosure bit budget. Retry only with a lower-cardinality response schema." + : ""; const errText = code ? `Error [${code}]: ${message}` : `Error: ${message}`; process.stderr.write(errText + "\n"); auditLog(serverName, { event: "tool_error", error: errText }); @@ -1418,6 +1478,9 @@ async function formatResponse(responseBody, serverName, toolName = "") { const output = outputParts.join("\n"); if (isErrorResult) { process.stderr.write(output + "\n"); + if (/bit-budget-exhausted/i.test(output)) { + process.stderr.write("Hint: the enclave response schema exceeded the finite-disclosure bit budget. Retry only with a lower-cardinality response schema.\n"); + } auditLog(serverName, { event: "tool_error", error: output }); core.setFailed(`[${serverName}] Tool returned isError=true: ${output.length} chars`); return; @@ -1431,6 +1494,9 @@ async function formatResponse(responseBody, serverName, toolName = "") { const resultStr = typeof result === "string" ? result : JSON.stringify(result); if (isErrorResult) { process.stderr.write(resultStr + "\n"); + if (/bit-budget-exhausted/i.test(resultStr)) { + process.stderr.write("Hint: the enclave response schema exceeded the finite-disclosure bit budget. Retry only with a lower-cardinality response schema.\n"); + } auditLog(serverName, { event: "tool_error", error: resultStr }); core.setFailed(`[${serverName}] Tool returned isError=true`); return; @@ -1467,7 +1533,8 @@ async function main() { }); // Load cached tools for help display - const tools = ensureSafeOutputsTools(loadTools(toolsFile), serverName, toolsFile); + let tools = await refreshDeferredToolsIfNeeded(loadTools(toolsFile), serverName, serverUrl, apiKey, toolsFile); + tools = ensureSafeOutputsTools(tools, serverName, toolsFile); // Route: --help or no args → show top-level help if (userArgs.length === 0 || userArgs[0] === "--help" || userArgs[0] === "-h") { @@ -1598,6 +1665,8 @@ module.exports = { hasStdinJsonPayload, readStdinSync, ensureSafeOutputsTools, + refreshDeferredToolsIfNeeded, + serverInCommaList, getToolCallTimeoutMs, auditLog, ensureAuditDir, diff --git a/actions/setup/js/mcp_cli_bridge.test.cjs b/actions/setup/js/mcp_cli_bridge.test.cjs index 419be374a3f..1730a9239db 100644 --- a/actions/setup/js/mcp_cli_bridge.test.cjs +++ b/actions/setup/js/mcp_cli_bridge.test.cjs @@ -14,6 +14,8 @@ import { main, parseToolArgs, readStdinSync, + refreshDeferredToolsIfNeeded, + serverInCommaList, shouldShowToolHelpForEmptyArgs, showHelp, showToolHelp, @@ -218,6 +220,60 @@ describe("mcp_cli_bridge.cjs", () => { } }); + it("detects exact names in comma-delimited deferred server lists", () => { + expect(serverInCommaList("awf-enclave", "safeoutputs, awf-enclave")).toBe(true); + expect(serverInCommaList("github", "awf-enclave")).toBe(false); + expect(serverInCommaList("awf", "awf-enclave")).toBe(false); + }); + + it("refreshes an empty deferred server tool cache from the live gateway", async () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "bridge-deferred-tools-")); + const toolsFile = path.join(tempDir, "awf-enclave.json"); + fs.writeFileSync(toolsFile, "[]", "utf8"); + const originalDeferred = process.env.GH_AW_MCP_DEFERRED_SERVERS; + process.env.GH_AW_MCP_DEFERRED_SERVERS = "awf-enclave"; + + const server = http.createServer((req, res) => { + let data = ""; + req.on("data", chunk => { + data += chunk; + }); + req.on("end", () => { + const parsed = JSON.parse(data || "{}"); + if (parsed.method === "initialize") { + res.writeHead(200, { "Content-Type": "application/json", "Mcp-Session-Id": "s1" }); + res.end(JSON.stringify({ jsonrpc: "2.0", id: parsed.id, result: {} })); + return; + } + if (parsed.method === "tools/list") { + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ jsonrpc: "2.0", id: parsed.id, result: { tools: [{ name: "enclave_run_agent" }] } })); + return; + } + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ jsonrpc: "2.0", result: {} })); + }); + }); + await new Promise(resolve => server.listen(0, "127.0.0.1", resolve)); + const address = server.address(); + const port = typeof address === "object" && address ? address.port : 0; + + try { + const refreshed = await refreshDeferredToolsIfNeeded([], "awf-enclave", `http://127.0.0.1:${port}/mcp/awf-enclave`, "key", toolsFile); + expect(refreshed).toEqual([{ name: "enclave_run_agent" }]); + expect(JSON.parse(fs.readFileSync(toolsFile, "utf8"))).toEqual([{ name: "enclave_run_agent" }]); + expect(global.core.warning).toHaveBeenCalledWith(expect.stringContaining("refreshing from live gateway")); + } finally { + await new Promise(resolve => server.close(resolve)); + if (originalDeferred === undefined) { + delete process.env.GH_AW_MCP_DEFERRED_SERVERS; + } else { + process.env.GH_AW_MCP_DEFERRED_SERVERS = originalDeferred; + } + fs.rmSync(tempDir, { recursive: true, force: true }); + } + }); + it("allows zero-argument tools to proceed — only shows help when required fields are declared", () => { // Empty schema (zero-input custom tool) — must NOT show help; empty call is valid const emptySchemaTools = { inputSchema: { type: "object", properties: {}, additionalProperties: false } }; @@ -448,6 +504,24 @@ describe("mcp_cli_bridge.cjs", () => { expect(process.exitCode).toBe(1); }); + it("adds a distinguishable hint for enclave bit-budget exhaustion", async () => { + await formatResponse( + { + result: { + isError: true, + content: [{ type: "text", text: '{"status":"error","reason":"bit-budget-exhausted"}' }], + }, + }, + "awf-enclave", + "enclave_run_agent" + ); + + const stderr = stderrChunks.join(""); + expect(stderr).toContain("bit-budget-exhausted"); + expect(stderr).toContain("finite-disclosure bit budget"); + expect(process.exitCode).toBe(1); + }); + it("omits non-retry hint when toolName is absent", async () => { await formatResponse( { diff --git a/actions/setup/sh/check_mcp_servers.sh b/actions/setup/sh/check_mcp_servers.sh index 6aae01a7563..0294b31a6bb 100755 --- a/actions/setup/sh/check_mcp_servers.sh +++ b/actions/setup/sh/check_mcp_servers.sh @@ -173,6 +173,9 @@ while IFS= read -r SERVER_NAME; do if echo "$SERVER_CONFIG" | jq -e '.headers.Authorization' >/dev/null 2>&1; then AUTH_HEADER=$(echo "$SERVER_CONFIG" | jq -r '.headers.Authorization' 2>/dev/null) fi + if [ "$SERVER_NAME" = "github" ] && [ -n "${GH_AW_MCP_GITHUB_CHECK_AGENT_ID:-}" ]; then + AUTH_HEADER="$GH_AW_MCP_GITHUB_CHECK_AGENT_ID" + fi # MCP protocol sequence: ping → initialize → tools/list. # ping verifies basic connectivity (may be handled by the gateway proxy). diff --git a/actions/setup/sh/check_mcp_servers_test.sh b/actions/setup/sh/check_mcp_servers_test.sh index 5b0722aa3dc..88d5c238ac6 100755 --- a/actions/setup/sh/check_mcp_servers_test.sh +++ b/actions/setup/sh/check_mcp_servers_test.sh @@ -42,6 +42,7 @@ const fs = require("fs"); const http = require("http"); const portFile = process.argv[2]; +const expectedGithubAuth = process.env.EXPECT_GITHUB_AUTH || ""; const send = (res, code, payload, sessionId) => { const body = JSON.stringify(payload); @@ -70,6 +71,10 @@ const server = http.createServer((req, res) => { const reqId = data.id ?? 1; if (req.url.endsWith("/github")) { + if (expectedGithubAuth && req.headers.authorization !== expectedGithubAuth) { + send(res, 400, { error: "wrong authorization header" }); + return; + } if (method === "initialize") { send(res, 200, { jsonrpc: "2.0", id: reqId, result: { protocolVersion: "2024-11-05", capabilities: {}, serverInfo: { name: "github", version: "1.0.0" } } }, "s1"); } else if (method === "tools/list") { @@ -899,6 +904,54 @@ EOF rm -rf "$tmpdir" } +# Test 20: Enclave-only GitHub can be probed with its dedicated identity +test_github_uses_enclave_check_identity_when_configured() { + echo "" + echo "Test 20: GitHub checker uses enclave identity when configured" + + local tmpdir + tmpdir=$(mktemp -d) + local port_file="$tmpdir/port" + local config_file="$tmpdir/config.json" + + local server_pid + export EXPECT_GITHUB_AUTH="enclave-key" + if ! server_pid=$(start_and_validate_mock_server "$port_file" "$tmpdir/mock.log"); then + unset EXPECT_GITHUB_AUTH + print_result "Mock MCP server failed to start (check $tmpdir/mock.log)" "FAIL" + return + fi + unset EXPECT_GITHUB_AUTH + + local port + port=$(cat "$port_file") + + cat > "$config_file" </dev/null 2>&1; then + print_result "GitHub checker used enclave identity" "PASS" + else + print_result "GitHub checker should use enclave identity" "FAIL" + fi + + kill "$server_pid" 2>/dev/null || true + wait "$server_pid" 2>/dev/null || true + rm -rf "$tmpdir" +} + # Run all tests echo "=== Testing check_mcp_servers.sh ===" echo "Script: $SCRIPT_PATH" @@ -922,6 +975,7 @@ test_deferred_enclave_server_is_not_probed test_deferred_enclave_with_healthy_required_server test_unclassified_enclave_server_is_fatal test_arbitrary_deferred_server_name_is_fatal +test_github_uses_enclave_check_identity_when_configured # Print summary echo "" diff --git a/pkg/workflow/enclave_github_proxy_test.go b/pkg/workflow/enclave_github_proxy_test.go index 1e5fe899947..b8156a82e35 100644 --- a/pkg/workflow/enclave_github_proxy_test.go +++ b/pkg/workflow/enclave_github_proxy_test.go @@ -340,6 +340,9 @@ enclaves: repos: - repo: octo-org/private-service sensitivity: confidential +safe-outputs: + add-comment: + max: 1 --- Read the private repository's issues through the enclave. @@ -366,7 +369,11 @@ Read the private repository's issues through the enclave. // broadens access beyond what the enclave identity already allows. assert.Contains(t, lock, `"min-integrity": "none"`) assert.Contains(t, lock, `"octo-org/private-service"`) + assert.Contains(t, lock, `"write-sink"`) + assert.Contains(t, lock, `"private:octo-org/private-service"`) + assert.Contains(t, lock, `"sink-visibility": "${GH_AW_SINK_VISIBILITY}"`) assert.Contains(t, lock, `"${AWF_ENCLAVE_GITHUB_MCP_AGENT_ID}":{"servers":["github"],"tools":{"github":["list_issues","issue_read"]},"allow-only":{"min-integrity":"none","repos":["octo-org/private-service"]}}`) + assert.Contains(t, lock, `export GH_AW_MCP_GITHUB_CHECK_AGENT_ID="${AWF_ENCLAVE_GITHUB_MCP_AGENT_ID}"`) assert.NotContains(t, lock, `"${MCP_GATEWAY_AGENT_ID}":{"servers":["awf-enclave","github"`) } diff --git a/pkg/workflow/mcp_cli_mount.go b/pkg/workflow/mcp_cli_mount.go index f2a6be9e384..7ef082c01b8 100644 --- a/pkg/workflow/mcp_cli_mount.go +++ b/pkg/workflow/mcp_cli_mount.go @@ -120,6 +120,9 @@ func getMCPCLIServerNames(data *WorkflowData) []string { //nolint:largefunc // E if IsMCPScriptsEnabled(data.MCPScripts) && !slices.Contains(servers, constants.MCPScriptsMCPServerID.String()) { servers = append(servers, constants.MCPScriptsMCPServerID.String()) } + if enclavesEnabled(data) && !slices.Contains(servers, enclaveMCPServerName) { + servers = append(servers, enclaveMCPServerName) + } // Copilot normally runs with --disable-builtin-mcps. When at least one CLI // mount trigger is active (safeoutputs/mcpscripts or cli-proxy), the mount @@ -364,9 +367,12 @@ func buildMCPCLIPromptSection(data *WorkflowData) *PromptSection { // Using step outputs (e.g. steps.mount-mcp-clis.outputs.mcp-cli-servers-list) here // would reference a step from the agent job in the activation job's env block, which // is out of scope and triggers actionlint errors. - lines := make([]string, len(servers)) - for i, server := range servers { - lines[i] = fmt.Sprintf("- `%s` — run `%s --help` to see available tools", server, server) + lines := make([]string, 0, len(servers)+4) + for _, server := range servers { + lines = append(lines, fmt.Sprintf("- `%s` — run `%s --help` to see available tools", server, server)) + } + if budgetLines := staticEnclaveInformationBudgetPromptLines(data); len(budgetLines) > 0 { + lines = append(lines, budgetLines...) } promptFile := mcpCLIToolsPromptFile @@ -382,3 +388,33 @@ func buildMCPCLIPromptSection(data *WorkflowData) *PromptSection { }, } } + +func staticEnclaveInformationBudgetPromptLines(data *WorkflowData) []string { + enclave := enclaveStaticGitHubAgentConfig(data) + if enclave == nil { + return nil + } + repoLines := make([]string, 0, len(enclave.Repos)) + for _, repo := range enclave.Repos { + if repo == nil { + continue + } + switch repo.Sensitivity { + case "confidential": + repoLines = append(repoLines, fmt.Sprintf("- `%s` (`confidential`) has an 8-bit per-run budget, so response schema cardinality must be at most 8.", repo.Repo)) + case "internal", "sealed": + repoLines = append(repoLines, fmt.Sprintf("- `%s` (`%s`) has a finite per-run budget; keep response schema cardinality within the budget reported by `awf-enclave --help`.", repo.Repo, repo.Sensitivity)) + } + } + if len(repoLines) == 0 { + return nil + } + lines := []string{ + "", + "For `awf-enclave enclave_run_agent`, response schemas are constrained by finite-disclosure information budgets, not just `max-output-bytes`.", + "The charge is 1 status bit + ceil(log2(response schema cardinality)) + 4 timing bits.", + } + lines = append(lines, repoLines...) + lines = append(lines, "Use small enums and booleans for finite schemas; a response can be under `max-output-bytes` and still fail with `bit-budget-exhausted` if its schema cardinality is too high.") + return lines +} diff --git a/pkg/workflow/mcp_cli_mount_test.go b/pkg/workflow/mcp_cli_mount_test.go index 6f28507f03e..347f205f7b6 100644 --- a/pkg/workflow/mcp_cli_mount_test.go +++ b/pkg/workflow/mcp_cli_mount_test.go @@ -245,6 +245,23 @@ func TestBuildMCPCLIPromptSection_UsesBaseTemplateWithoutSafeOutputs(t *testing. assert.Equal(t, mcpCLIToolsPromptFile, section.Content) } +func TestBuildMCPCLIPromptSection_StaticEnclaveBudgetGuidance(t *testing.T) { + data := enclaveGitHubToolsWorkflowData() + data.EngineConfig = &EngineConfig{ID: string(constants.CopilotEngine)} + data.SafeOutputs = &SafeOutputsConfig{AddComments: &AddCommentsConfig{}} + data.Enclaves[0].Repos = []*EnclaveRepository{{Repo: "octo-org/private-service", Sensitivity: "confidential"}} + + section := buildMCPCLIPromptSection(data) + require.NotNil(t, section) + + serversList := section.EnvVars["GH_AW_MCP_CLI_SERVERS_LIST"] + assert.Contains(t, serversList, "awf-enclave") + assert.Contains(t, serversList, "8-bit per-run budget") + assert.Contains(t, serversList, "response schema cardinality must be at most 8") + assert.Contains(t, serversList, "bit-budget-exhausted") + assert.Contains(t, serversList, "not just `max-output-bytes`") +} + func TestGetMCPCLIServerNames_CopilotIncludesManifestServersInPromptList(t *testing.T) { t.Run("copilot adds github and custom MCP servers when CLI mounts are active", func(t *testing.T) { data := &WorkflowData{ diff --git a/pkg/workflow/mcp_github_config.go b/pkg/workflow/mcp_github_config.go index 2be2eb88e92..7bfa78696ca 100644 --- a/pkg/workflow/mcp_github_config.go +++ b/pkg/workflow/mcp_github_config.go @@ -182,6 +182,32 @@ func dynamicEnclaveWriteSinkGuardPolicy(workflowData *WorkflowData) map[string]a } } +// staticEnclaveWriteSinkGuardPolicy builds the write-sink policy required by +// safeoutputs when the only guarded GitHub source is a static enclave agent. +func staticEnclaveWriteSinkGuardPolicy(workflowData *WorkflowData) map[string]any { + if !githubBackendIsStaticEnclaveDelegationOnly(workflowData) { + return nil + } + enclave := enclaveStaticGitHubAgentConfig(workflowData) + if enclave == nil { + return nil + } + repos := enclaveGitHubAllowedRepos(enclave) + if len(repos) == 0 { + return nil + } + accept := make([]string, 0, len(repos)) + for _, repo := range repos { + accept = append(accept, transformRepoPattern(repo)) + } + return map[string]any{ + "write-sink": map[string]any{ + "accept": accept, + "sink-visibility": sinkVisibilityRuntimeExpr, + }, + } +} + // hasGitHubTool checks if the GitHub tool is configured (using ParsedTools) func hasGitHubTool(parsedTools *Tools) bool { if parsedTools == nil { @@ -660,6 +686,9 @@ func deriveWriteSinkGuardPolicyFromWorkflow(workflowData *WorkflowData) map[stri } rawGithubTool, hasGitHub := workflowData.Tools["github"] if !hasGitHub { + if policy := staticEnclaveWriteSinkGuardPolicy(workflowData); policy != nil { + return policy + } return dynamicEnclaveWriteSinkGuardPolicy(workflowData) } @@ -702,6 +731,9 @@ func deriveWriteSinkGuardPolicyFromWorkflow(workflowData *WorkflowData) map[stri } if rawGithubTool == false { + if policy := staticEnclaveWriteSinkGuardPolicy(workflowData); policy != nil { + return policy + } return dynamicEnclaveWriteSinkGuardPolicy(workflowData) } diff --git a/pkg/workflow/mcp_setup_gateway.go b/pkg/workflow/mcp_setup_gateway.go index 99ad4e07935..69bc4fd1d7c 100644 --- a/pkg/workflow/mcp_setup_gateway.go +++ b/pkg/workflow/mcp_setup_gateway.go @@ -363,6 +363,9 @@ func writeMCPGatewayExports(yaml *strings.Builder, opts writeMCPGatewayExportsOp yaml.WriteString(" AWF_ENCLAVE_GITHUB_MCP_AGENT_ID=$(openssl rand -base64 45 | tr -d '/+=')\n") yaml.WriteString(" echo \"::add-mask::${AWF_ENCLAVE_GITHUB_MCP_AGENT_ID}\"\n") yaml.WriteString(" export AWF_ENCLAVE_GITHUB_MCP_AGENT_ID\n") + if githubBackendIsStaticEnclaveDelegationOnly(workflowData) { + yaml.WriteString(" export GH_AW_MCP_GITHUB_CHECK_AGENT_ID=\"${AWF_ENCLAVE_GITHUB_MCP_AGENT_ID}\"\n") + } } yaml.WriteString(" # The eager checker runs inside start_mcp_gateway.cjs in this step.\n") fmt.Fprintf(yaml, " export %s=%q\n", enclaveMCPDeferredServersEnv, enclaveMCPServerName) From f660e2244232be1f6b0c98c98ae971d6c878d697 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 10 Sep 2026 05:00:25 +0000 Subject: [PATCH 03/10] Address static enclave review feedback Co-authored-by: lpcox <15877973+lpcox@users.noreply.github.com> --- pkg/workflow/mcp_cli_mount.go | 2 ++ pkg/workflow/mcp_github_config.go | 20 ++++++++++---------- 2 files changed, 12 insertions(+), 10 deletions(-) diff --git a/pkg/workflow/mcp_cli_mount.go b/pkg/workflow/mcp_cli_mount.go index 7ef082c01b8..4190a269d98 100644 --- a/pkg/workflow/mcp_cli_mount.go +++ b/pkg/workflow/mcp_cli_mount.go @@ -412,6 +412,8 @@ func staticEnclaveInformationBudgetPromptLines(data *WorkflowData) []string { lines := []string{ "", "For `awf-enclave enclave_run_agent`, response schemas are constrained by finite-disclosure information budgets, not just `max-output-bytes`.", + // Keep these constants aligned with gh-aw-firewall's finite disclosure charge: + // RESULT_STATUS_BIT_COST=1 and TIMING_BUCKET_BITS=4. "The charge is 1 status bit + ceil(log2(response schema cardinality)) + 4 timing bits.", } lines = append(lines, repoLines...) diff --git a/pkg/workflow/mcp_github_config.go b/pkg/workflow/mcp_github_config.go index 7bfa78696ca..c52273dd3ac 100644 --- a/pkg/workflow/mcp_github_config.go +++ b/pkg/workflow/mcp_github_config.go @@ -169,17 +169,9 @@ func dynamicEnclaveWriteSinkGuardPolicy(workflowData *WorkflowData) map[string]a accept := []string{"*"} if enclave.Dynamic.Sensitivity != "public" { repos := dynamicEnclaveGitHubGuardRepos(workflowData) - accept = make([]string, 0, len(repos)) - for _, repo := range repos { - accept = append(accept, transformRepoPattern(repo)) - } - } - return map[string]any{ - "write-sink": map[string]any{ - "accept": accept, - "sink-visibility": sinkVisibilityRuntimeExpr, - }, + accept = writeSinkAcceptLabelsForRepos(repos) } + return writeSinkGuardPolicy(accept) } // staticEnclaveWriteSinkGuardPolicy builds the write-sink policy required by @@ -196,10 +188,18 @@ func staticEnclaveWriteSinkGuardPolicy(workflowData *WorkflowData) map[string]an if len(repos) == 0 { return nil } + return writeSinkGuardPolicy(writeSinkAcceptLabelsForRepos(repos)) +} + +func writeSinkAcceptLabelsForRepos(repos []string) []string { accept := make([]string, 0, len(repos)) for _, repo := range repos { accept = append(accept, transformRepoPattern(repo)) } + return accept +} + +func writeSinkGuardPolicy(accept []string) map[string]any { return map[string]any{ "write-sink": map[string]any{ "accept": accept, From d08b9f457562392b1e9acbb6595c05b4b81d3d53 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 10 Sep 2026 05:05:05 +0000 Subject: [PATCH 04/10] Polish static enclave validation fixes Co-authored-by: lpcox <15877973+lpcox@users.noreply.github.com> --- actions/setup/js/mcp_cli_bridge.cjs | 7 ++++--- pkg/workflow/mcp_cli_mount.go | 5 +++-- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/actions/setup/js/mcp_cli_bridge.cjs b/actions/setup/js/mcp_cli_bridge.cjs index 0a70a2207c6..601a18e408f 100644 --- a/actions/setup/js/mcp_cli_bridge.cjs +++ b/actions/setup/js/mcp_cli_bridge.cjs @@ -58,6 +58,7 @@ const LOGS_TOOL_MIN_TIMEOUT_MINUTES_NO_FILTER = 5; const LOGS_TOOL_MAX_EXPLICIT_TIMEOUT_MINUTES = 60; /** Extra time (ms) to allow response marshalling/transport after tool execution */ const TOOL_CALL_TIMEOUT_BUFFER_MS = 15000; +const ENCLAVE_BIT_BUDGET_HINT = "Hint: the enclave response schema exceeded the finite-disclosure bit budget. Retry only with a lower-cardinality response schema."; /** Timeout (ms) for the notifications/initialized handshake step */ const NOTIFY_TIMEOUT_MS = 10000; @@ -1449,7 +1450,7 @@ async function formatResponse(responseBody, serverName, toolName = "") { isSafeOutputsEmptyArgs && toolName ? `Hint: do not retry '${serverName} ${toolName}' with empty arguments. Run '${serverName} ${toolName} --help' to inspect the required options, or call 'noop' with a message if no action is needed.` : isEnclaveBitBudget - ? "Hint: the enclave response schema exceeded the finite-disclosure bit budget. Retry only with a lower-cardinality response schema." + ? ENCLAVE_BIT_BUDGET_HINT : ""; const errText = code ? `Error [${code}]: ${message}` : `Error: ${message}`; process.stderr.write(errText + "\n"); @@ -1479,7 +1480,7 @@ async function formatResponse(responseBody, serverName, toolName = "") { if (isErrorResult) { process.stderr.write(output + "\n"); if (/bit-budget-exhausted/i.test(output)) { - process.stderr.write("Hint: the enclave response schema exceeded the finite-disclosure bit budget. Retry only with a lower-cardinality response schema.\n"); + process.stderr.write(ENCLAVE_BIT_BUDGET_HINT + "\n"); } auditLog(serverName, { event: "tool_error", error: output }); core.setFailed(`[${serverName}] Tool returned isError=true: ${output.length} chars`); @@ -1495,7 +1496,7 @@ async function formatResponse(responseBody, serverName, toolName = "") { if (isErrorResult) { process.stderr.write(resultStr + "\n"); if (/bit-budget-exhausted/i.test(resultStr)) { - process.stderr.write("Hint: the enclave response schema exceeded the finite-disclosure bit budget. Retry only with a lower-cardinality response schema.\n"); + process.stderr.write(ENCLAVE_BIT_BUDGET_HINT + "\n"); } auditLog(serverName, { event: "tool_error", error: resultStr }); core.setFailed(`[${serverName}] Tool returned isError=true`); diff --git a/pkg/workflow/mcp_cli_mount.go b/pkg/workflow/mcp_cli_mount.go index 4190a269d98..ea5f250f3e9 100644 --- a/pkg/workflow/mcp_cli_mount.go +++ b/pkg/workflow/mcp_cli_mount.go @@ -367,11 +367,12 @@ func buildMCPCLIPromptSection(data *WorkflowData) *PromptSection { // Using step outputs (e.g. steps.mount-mcp-clis.outputs.mcp-cli-servers-list) here // would reference a step from the agent job in the activation job's env block, which // is out of scope and triggers actionlint errors. - lines := make([]string, 0, len(servers)+4) + budgetLines := staticEnclaveInformationBudgetPromptLines(data) + lines := make([]string, 0, len(servers)+len(budgetLines)) for _, server := range servers { lines = append(lines, fmt.Sprintf("- `%s` — run `%s --help` to see available tools", server, server)) } - if budgetLines := staticEnclaveInformationBudgetPromptLines(data); len(budgetLines) > 0 { + if len(budgetLines) > 0 { lines = append(lines, budgetLines...) } From a791bb7a0dc390aa488db5115acef94e29c99244 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 10 Sep 2026 05:08:00 +0000 Subject: [PATCH 05/10] Scope enclave budget hint to enclave CLI Co-authored-by: lpcox <15877973+lpcox@users.noreply.github.com> --- actions/setup/js/mcp_cli_bridge.cjs | 11 ++++++++--- actions/setup/js/mcp_cli_bridge.test.cjs | 18 ++++++++++++++++++ 2 files changed, 26 insertions(+), 3 deletions(-) diff --git a/actions/setup/js/mcp_cli_bridge.cjs b/actions/setup/js/mcp_cli_bridge.cjs index 601a18e408f..26cb8d665b6 100644 --- a/actions/setup/js/mcp_cli_bridge.cjs +++ b/actions/setup/js/mcp_cli_bridge.cjs @@ -75,6 +75,7 @@ const TOOL_HELP_MAX_LINES = 30; const TOOL_DESC_MAX_LEN = 90; const COMPACT_NAME_LINE_TARGET_WIDTH = 110; const SAFEOUTPUTS_SERVER_NAME = "safeoutputs"; +const AWF_ENCLAVE_SERVER_NAME = "awf-enclave"; const DEFERRED_SERVERS_ENV = "GH_AW_MCP_DEFERRED_SERVERS"; // --------------------------------------------------------------------------- @@ -1125,6 +1126,10 @@ function serverInCommaList(name, list) { .includes(name); } +function isEnclaveBitBudgetExhausted(serverName, message) { + return serverName === AWF_ENCLAVE_SERVER_NAME && /bit-budget-exhausted/i.test(message); +} + /** * Fetch the live tools/list result for a server and persist it over an empty * cache. Deferred servers such as awf-enclave may register after the wrapper is @@ -1445,7 +1450,7 @@ async function formatResponse(responseBody, serverName, toolName = "") { const message = "message" in errRecord ? String(errRecord.message || "Unknown error") : "Unknown error"; const code = "code" in errRecord && errRecord.code != null ? String(errRecord.code) : ""; const isSafeOutputsEmptyArgs = serverName === SAFEOUTPUTS_SERVER_NAME && code === "-32602" && /Empty arguments are not allowed/i.test(message); - const isEnclaveBitBudget = /bit-budget-exhausted/i.test(message); + const isEnclaveBitBudget = isEnclaveBitBudgetExhausted(serverName, message); const hint = isSafeOutputsEmptyArgs && toolName ? `Hint: do not retry '${serverName} ${toolName}' with empty arguments. Run '${serverName} ${toolName} --help' to inspect the required options, or call 'noop' with a message if no action is needed.` @@ -1479,7 +1484,7 @@ async function formatResponse(responseBody, serverName, toolName = "") { const output = outputParts.join("\n"); if (isErrorResult) { process.stderr.write(output + "\n"); - if (/bit-budget-exhausted/i.test(output)) { + if (isEnclaveBitBudgetExhausted(serverName, output)) { process.stderr.write(ENCLAVE_BIT_BUDGET_HINT + "\n"); } auditLog(serverName, { event: "tool_error", error: output }); @@ -1495,7 +1500,7 @@ async function formatResponse(responseBody, serverName, toolName = "") { const resultStr = typeof result === "string" ? result : JSON.stringify(result); if (isErrorResult) { process.stderr.write(resultStr + "\n"); - if (/bit-budget-exhausted/i.test(resultStr)) { + if (isEnclaveBitBudgetExhausted(serverName, resultStr)) { process.stderr.write(ENCLAVE_BIT_BUDGET_HINT + "\n"); } auditLog(serverName, { event: "tool_error", error: resultStr }); diff --git a/actions/setup/js/mcp_cli_bridge.test.cjs b/actions/setup/js/mcp_cli_bridge.test.cjs index 1730a9239db..cdf7c9d0ee8 100644 --- a/actions/setup/js/mcp_cli_bridge.test.cjs +++ b/actions/setup/js/mcp_cli_bridge.test.cjs @@ -522,6 +522,24 @@ describe("mcp_cli_bridge.cjs", () => { expect(process.exitCode).toBe(1); }); + it("does not add enclave bit-budget hints for other servers", async () => { + await formatResponse( + { + result: { + isError: true, + content: [{ type: "text", text: '{"status":"error","reason":"bit-budget-exhausted"}' }], + }, + }, + "other-server", + "some_tool" + ); + + const stderr = stderrChunks.join(""); + expect(stderr).toContain("bit-budget-exhausted"); + expect(stderr).not.toContain("finite-disclosure bit budget"); + expect(process.exitCode).toBe(1); + }); + it("omits non-retry hint when toolName is absent", async () => { await formatResponse( { From 7bde79496955bc8dd984e28a1536c67f2299a469 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 10 Sep 2026 05:13:01 +0000 Subject: [PATCH 06/10] Derive enclave prompt budget figures Co-authored-by: lpcox <15877973+lpcox@users.noreply.github.com> --- pkg/workflow/mcp_cli_mount.go | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/pkg/workflow/mcp_cli_mount.go b/pkg/workflow/mcp_cli_mount.go index ea5f250f3e9..54329a16b2c 100644 --- a/pkg/workflow/mcp_cli_mount.go +++ b/pkg/workflow/mcp_cli_mount.go @@ -13,6 +13,13 @@ import ( var mcpCLIMountLog = logger.New("workflow:mcp_cli_mount") +const ( + // Keep these aligned with gh-aw-firewall's finite disclosure charge constants. + enclaveResultStatusBitCost = 1 + enclaveTimingBucketBits = 4 + enclaveConfidentialRunBits = 8 +) + // mcp_cli_mount.go generates a workflow step that mounts MCP servers as local CLI tools // and produces the prompt section that informs the agent about these tools. // @@ -402,7 +409,9 @@ func staticEnclaveInformationBudgetPromptLines(data *WorkflowData) []string { } switch repo.Sensitivity { case "confidential": - repoLines = append(repoLines, fmt.Sprintf("- `%s` (`confidential`) has an 8-bit per-run budget, so response schema cardinality must be at most 8.", repo.Repo)) + payloadBits := enclaveConfidentialRunBits - enclaveResultStatusBitCost - enclaveTimingBucketBits + maxCardinality := 1 << payloadBits + repoLines = append(repoLines, fmt.Sprintf("- `%s` (`confidential`) has an %d-bit per-run budget, so response schema cardinality must be at most %d.", repo.Repo, enclaveConfidentialRunBits, maxCardinality)) case "internal", "sealed": repoLines = append(repoLines, fmt.Sprintf("- `%s` (`%s`) has a finite per-run budget; keep response schema cardinality within the budget reported by `awf-enclave --help`.", repo.Repo, repo.Sensitivity)) } From a50f6be428f4c46f18465b98cac9a398f6b279c7 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 10 Sep 2026 05:17:51 +0000 Subject: [PATCH 07/10] Clarify enclave retry constants Co-authored-by: lpcox <15877973+lpcox@users.noreply.github.com> --- actions/setup/js/mcp_cli_bridge.cjs | 3 ++- pkg/workflow/mcp_cli_mount.go | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/actions/setup/js/mcp_cli_bridge.cjs b/actions/setup/js/mcp_cli_bridge.cjs index 26cb8d665b6..39a0b4b31d7 100644 --- a/actions/setup/js/mcp_cli_bridge.cjs +++ b/actions/setup/js/mcp_cli_bridge.cjs @@ -48,6 +48,7 @@ const DEFAULT_HTTP_TIMEOUT_MS = 15000; /** Timeout (ms) for tool invocation calls (may be long-running) */ const TOOL_CALL_TIMEOUT_MS = 120000; +const TOOLS_LIST_REQUEST_ID = 3; /** Default run count for logs MCP calls when count is not provided (mirrors server default) */ const LOGS_TOOL_DEFAULT_COUNT = 100; /** Number of runs per timeout minute for logs auto-scaling (mirrors server: ceil(count/40)) */ @@ -1155,7 +1156,7 @@ async function refreshDeferredToolsIfNeeded(tools, serverName, serverUrl, apiKey if (sessionId) { headers["Mcp-Session-Id"] = sessionId; } - const resp = await httpPostJSON(serverUrl, headers, { jsonrpc: "2.0", id: 3, method: "tools/list" }, DEFAULT_HTTP_TIMEOUT_MS); + const resp = await httpPostJSON(serverUrl, headers, { jsonrpc: "2.0", id: TOOLS_LIST_REQUEST_ID, method: "tools/list" }, DEFAULT_HTTP_TIMEOUT_MS); const messages = extractJSONRPCMessages(resp.body); const resultMessage = messages.find(isResultMessage); const result = resultMessage && typeof resultMessage === "object" && "result" in resultMessage && resultMessage.result && typeof resultMessage.result === "object" ? resultMessage.result : null; diff --git a/pkg/workflow/mcp_cli_mount.go b/pkg/workflow/mcp_cli_mount.go index 54329a16b2c..3143227f875 100644 --- a/pkg/workflow/mcp_cli_mount.go +++ b/pkg/workflow/mcp_cli_mount.go @@ -423,7 +423,8 @@ func staticEnclaveInformationBudgetPromptLines(data *WorkflowData) []string { "", "For `awf-enclave enclave_run_agent`, response schemas are constrained by finite-disclosure information budgets, not just `max-output-bytes`.", // Keep these constants aligned with gh-aw-firewall's finite disclosure charge: - // RESULT_STATUS_BIT_COST=1 and TIMING_BUCKET_BITS=4. + // RESULT_STATUS_BIT_COST=1, TIMING_BUCKET_BITS=4, and + // ENCLAVE_SENSITIVITY_RUN_BITS.confidential=8. "The charge is 1 status bit + ceil(log2(response schema cardinality)) + 4 timing bits.", } lines = append(lines, repoLines...) From e293feb66a6d192e58bde306deab7f3c6a310f9d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 10 Sep 2026 10:19:40 +0000 Subject: [PATCH 08/10] Harden deferred enclave CLI schema refresh Co-authored-by: gh-aw-bot <259018956+gh-aw-bot@users.noreply.github.com> --- actions/setup/js/mcp_cli_bridge.cjs | 59 +++++++++++++++--------- actions/setup/js/mcp_cli_bridge.test.cjs | 51 +++++++++++++++++++- pkg/workflow/mcp_cli_mount.go | 4 +- pkg/workflow/mcp_cli_mount_test.go | 14 ++++++ 4 files changed, 102 insertions(+), 26 deletions(-) diff --git a/actions/setup/js/mcp_cli_bridge.cjs b/actions/setup/js/mcp_cli_bridge.cjs index 39a0b4b31d7..14db9d8bb0b 100644 --- a/actions/setup/js/mcp_cli_bridge.cjs +++ b/actions/setup/js/mcp_cli_bridge.cjs @@ -78,6 +78,8 @@ const COMPACT_NAME_LINE_TARGET_WIDTH = 110; const SAFEOUTPUTS_SERVER_NAME = "safeoutputs"; const AWF_ENCLAVE_SERVER_NAME = "awf-enclave"; const DEFERRED_SERVERS_ENV = "GH_AW_MCP_DEFERRED_SERVERS"; +const DEFERRED_TOOLS_LIST_MAX_ATTEMPTS = 5; +const DEFERRED_TOOLS_LIST_RETRY_DELAY_MS = 1000; // --------------------------------------------------------------------------- // Audit logging @@ -1144,34 +1146,45 @@ function isEnclaveBitBudgetExhausted(serverName, message) { * @returns {Promise, required?: string[]}}>>} */ async function refreshDeferredToolsIfNeeded(tools, serverName, serverUrl, apiKey, toolsFile) { - if (tools.length > 0 || !serverInCommaList(serverName, process.env[DEFERRED_SERVERS_ENV] || "")) { + const isDeferredServer = serverName === AWF_ENCLAVE_SERVER_NAME || serverInCommaList(serverName, process.env[DEFERRED_SERVERS_ENV] || ""); + if (tools.length > 0 || !isDeferredServer) { return tools; } const core = global.core; core.warning(`[${serverName}] cached tool schema is empty for deferred server; refreshing from live gateway`); - const sessionId = await mcpInitialize(serverUrl, apiKey, serverName); - await mcpNotifyInitialized(serverUrl, apiKey, sessionId, serverName); - /** @type {Record} */ - const headers = { Authorization: apiKey }; - if (sessionId) { - headers["Mcp-Session-Id"] = sessionId; - } - const resp = await httpPostJSON(serverUrl, headers, { jsonrpc: "2.0", id: TOOLS_LIST_REQUEST_ID, method: "tools/list" }, DEFAULT_HTTP_TIMEOUT_MS); - const messages = extractJSONRPCMessages(resp.body); - const resultMessage = messages.find(isResultMessage); - const result = resultMessage && typeof resultMessage === "object" && "result" in resultMessage && resultMessage.result && typeof resultMessage.result === "object" ? resultMessage.result : null; - const refreshed = result && "tools" in result && Array.isArray(result.tools) ? result.tools : []; - if (refreshed.length === 0) { - core.warning(`[${serverName}] live tools/list still returned 0 tools for deferred server`); - return tools; - } - try { - fs.writeFileSync(toolsFile, JSON.stringify(refreshed, null, 2), { mode: 0o644 }); - } catch (err) { - core.warning(`[${serverName}] failed to update refreshed tools cache ${toolsFile}: ${getErrorMessage(err)}`); + for (let attempt = 1; attempt <= DEFERRED_TOOLS_LIST_MAX_ATTEMPTS; attempt++) { + try { + const sessionId = await mcpInitialize(serverUrl, apiKey, serverName); + await mcpNotifyInitialized(serverUrl, apiKey, sessionId, serverName); + /** @type {Record} */ + const headers = { Authorization: apiKey }; + if (sessionId) { + headers["Mcp-Session-Id"] = sessionId; + } + const resp = await httpPostJSON(serverUrl, headers, { jsonrpc: "2.0", id: TOOLS_LIST_REQUEST_ID, method: "tools/list" }, DEFAULT_HTTP_TIMEOUT_MS); + const messages = extractJSONRPCMessages(resp.body); + const resultMessage = messages.find(isResultMessage); + const result = resultMessage && typeof resultMessage === "object" && "result" in resultMessage && resultMessage.result && typeof resultMessage.result === "object" ? resultMessage.result : null; + const refreshed = result && "tools" in result && Array.isArray(result.tools) ? result.tools : []; + if (refreshed.length > 0) { + try { + fs.writeFileSync(toolsFile, JSON.stringify(refreshed, null, 2), { mode: 0o644 }); + } catch (err) { + core.warning(`[${serverName}] failed to update refreshed tools cache ${toolsFile}: ${getErrorMessage(err)}`); + } + core.info(`[${serverName}] refreshed deferred tools cache with ${refreshed.length} tool(s)`); + return refreshed; + } + core.warning(`[${serverName}] live tools/list attempt ${attempt}/${DEFERRED_TOOLS_LIST_MAX_ATTEMPTS} returned 0 tools for deferred server`); + } catch (err) { + core.warning(`[${serverName}] deferred tools/list attempt ${attempt}/${DEFERRED_TOOLS_LIST_MAX_ATTEMPTS} failed: ${getErrorMessage(err)}`); + } + if (attempt < DEFERRED_TOOLS_LIST_MAX_ATTEMPTS) { + await new Promise(resolve => setTimeout(resolve, DEFERRED_TOOLS_LIST_RETRY_DELAY_MS)); + } } - core.info(`[${serverName}] refreshed deferred tools cache with ${refreshed.length} tool(s)`); - return refreshed; + core.warning(`[${serverName}] deferred tools/list refresh exhausted retries; using cached empty schema`); + return tools; } /** diff --git a/actions/setup/js/mcp_cli_bridge.test.cjs b/actions/setup/js/mcp_cli_bridge.test.cjs index cdf7c9d0ee8..0826845e033 100644 --- a/actions/setup/js/mcp_cli_bridge.test.cjs +++ b/actions/setup/js/mcp_cli_bridge.test.cjs @@ -226,12 +226,12 @@ describe("mcp_cli_bridge.cjs", () => { expect(serverInCommaList("awf", "awf-enclave")).toBe(false); }); - it("refreshes an empty deferred server tool cache from the live gateway", async () => { + it("refreshes an empty awf-enclave tool cache from the live gateway without deferred marker", async () => { const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "bridge-deferred-tools-")); const toolsFile = path.join(tempDir, "awf-enclave.json"); fs.writeFileSync(toolsFile, "[]", "utf8"); const originalDeferred = process.env.GH_AW_MCP_DEFERRED_SERVERS; - process.env.GH_AW_MCP_DEFERRED_SERVERS = "awf-enclave"; + delete process.env.GH_AW_MCP_DEFERRED_SERVERS; const server = http.createServer((req, res) => { let data = ""; @@ -274,6 +274,53 @@ describe("mcp_cli_bridge.cjs", () => { } }); + it("retries deferred tools refresh until tools become available", async () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "bridge-deferred-retry-")); + const toolsFile = path.join(tempDir, "awf-enclave.json"); + fs.writeFileSync(toolsFile, "[]", "utf8"); + let toolsListCalls = 0; + + const server = http.createServer((req, res) => { + let data = ""; + req.on("data", chunk => { + data += chunk; + }); + req.on("end", () => { + const parsed = JSON.parse(data || "{}"); + if (parsed.method === "initialize") { + res.writeHead(200, { "Content-Type": "application/json", "Mcp-Session-Id": "s1" }); + res.end(JSON.stringify({ jsonrpc: "2.0", id: parsed.id, result: {} })); + return; + } + if (parsed.method === "tools/list") { + toolsListCalls += 1; + res.writeHead(200, { "Content-Type": "application/json" }); + if (toolsListCalls < 2) { + res.end(JSON.stringify({ jsonrpc: "2.0", id: parsed.id, result: { tools: [] } })); + } else { + res.end(JSON.stringify({ jsonrpc: "2.0", id: parsed.id, result: { tools: [{ name: "enclave_run_agent" }] } })); + } + return; + } + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ jsonrpc: "2.0", result: {} })); + }); + }); + await new Promise(resolve => server.listen(0, "127.0.0.1", resolve)); + const address = server.address(); + const port = typeof address === "object" && address ? address.port : 0; + + try { + const refreshed = await refreshDeferredToolsIfNeeded([], "awf-enclave", `http://127.0.0.1:${port}/mcp/awf-enclave`, "key", toolsFile); + expect(refreshed).toEqual([{ name: "enclave_run_agent" }]); + expect(toolsListCalls).toBe(2); + expect(global.core.warning).toHaveBeenCalledWith(expect.stringContaining("attempt 1/5 returned 0 tools")); + } finally { + await new Promise(resolve => server.close(resolve)); + fs.rmSync(tempDir, { recursive: true, force: true }); + } + }); + it("allows zero-argument tools to proceed — only shows help when required fields are declared", () => { // Empty schema (zero-input custom tool) — must NOT show help; empty call is valid const emptySchemaTools = { inputSchema: { type: "object", properties: {}, additionalProperties: false } }; diff --git a/pkg/workflow/mcp_cli_mount.go b/pkg/workflow/mcp_cli_mount.go index 3143227f875..badeb8d25d0 100644 --- a/pkg/workflow/mcp_cli_mount.go +++ b/pkg/workflow/mcp_cli_mount.go @@ -412,8 +412,10 @@ func staticEnclaveInformationBudgetPromptLines(data *WorkflowData) []string { payloadBits := enclaveConfidentialRunBits - enclaveResultStatusBitCost - enclaveTimingBucketBits maxCardinality := 1 << payloadBits repoLines = append(repoLines, fmt.Sprintf("- `%s` (`confidential`) has an %d-bit per-run budget, so response schema cardinality must be at most %d.", repo.Repo, enclaveConfidentialRunBits, maxCardinality)) - case "internal", "sealed": + case "internal": repoLines = append(repoLines, fmt.Sprintf("- `%s` (`%s`) has a finite per-run budget; keep response schema cardinality within the budget reported by `awf-enclave --help`.", repo.Repo, repo.Sensitivity)) + case "sealed": + repoLines = append(repoLines, fmt.Sprintf("- `%s` (`sealed`) has a 0-bit per-run budget and never launches an enclave; do not invoke `awf-enclave enclave_run_agent` for this repository.", repo.Repo)) } } if len(repoLines) == 0 { diff --git a/pkg/workflow/mcp_cli_mount_test.go b/pkg/workflow/mcp_cli_mount_test.go index 347f205f7b6..5da7b0ff37d 100644 --- a/pkg/workflow/mcp_cli_mount_test.go +++ b/pkg/workflow/mcp_cli_mount_test.go @@ -262,6 +262,20 @@ func TestBuildMCPCLIPromptSection_StaticEnclaveBudgetGuidance(t *testing.T) { assert.Contains(t, serversList, "not just `max-output-bytes`") } +func TestBuildMCPCLIPromptSection_SealedRepoGuidance(t *testing.T) { + data := enclaveGitHubToolsWorkflowData() + data.EngineConfig = &EngineConfig{ID: string(constants.CopilotEngine)} + data.SafeOutputs = &SafeOutputsConfig{AddComments: &AddCommentsConfig{}} + data.Enclaves[0].Repos = []*EnclaveRepository{{Repo: "octo-org/sealed-service", Sensitivity: "sealed"}} + + section := buildMCPCLIPromptSection(data) + require.NotNil(t, section) + + serversList := section.EnvVars["GH_AW_MCP_CLI_SERVERS_LIST"] + assert.Contains(t, serversList, "0-bit per-run budget and never launches an enclave") + assert.Contains(t, serversList, "do not invoke `awf-enclave enclave_run_agent`") +} + func TestGetMCPCLIServerNames_CopilotIncludesManifestServersInPromptList(t *testing.T) { t.Run("copilot adds github and custom MCP servers when CLI mounts are active", func(t *testing.T) { data := &WorkflowData{ From ddb071253cf002316e84c86150525a146215e3c7 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 10 Sep 2026 10:26:47 +0000 Subject: [PATCH 09/10] Retry deferred enclave tools refresh Co-authored-by: gh-aw-bot <259018956+gh-aw-bot@users.noreply.github.com> --- actions/setup/js/mcp_cli_bridge.cjs | 21 +++++++---- actions/setup/js/mcp_cli_bridge.test.cjs | 48 +++++++++++++++++++++++- 2 files changed, 59 insertions(+), 10 deletions(-) diff --git a/actions/setup/js/mcp_cli_bridge.cjs b/actions/setup/js/mcp_cli_bridge.cjs index 14db9d8bb0b..08f93d2ecde 100644 --- a/actions/setup/js/mcp_cli_bridge.cjs +++ b/actions/setup/js/mcp_cli_bridge.cjs @@ -1135,24 +1135,29 @@ function isEnclaveBitBudgetExhausted(serverName, message) { /** * Fetch the live tools/list result for a server and persist it over an empty - * cache. Deferred servers such as awf-enclave may register after the wrapper is - * mounted, so their startup-time cache can legitimately be empty. + * cache. The awf-enclave server is always treated as deferred, and any server in + * GH_AW_MCP_DEFERRED_SERVERS is also treated as deferred. These servers may + * register after the wrapper is mounted, so their startup-time cache can + * legitimately be empty. The refresh retries bounded attempts before falling + * back to the cached tools. * * @param {Array<{name: string, description?: string, inputSchema?: {properties?: Record, required?: string[]}}>} tools * @param {string} serverName * @param {string} serverUrl * @param {string} apiKey * @param {string} toolsFile + * @param {number} [maxAttempts] + * @param {number} [retryDelayMs] * @returns {Promise, required?: string[]}}>>} */ -async function refreshDeferredToolsIfNeeded(tools, serverName, serverUrl, apiKey, toolsFile) { +async function refreshDeferredToolsIfNeeded(tools, serverName, serverUrl, apiKey, toolsFile, maxAttempts = DEFERRED_TOOLS_LIST_MAX_ATTEMPTS, retryDelayMs = DEFERRED_TOOLS_LIST_RETRY_DELAY_MS) { const isDeferredServer = serverName === AWF_ENCLAVE_SERVER_NAME || serverInCommaList(serverName, process.env[DEFERRED_SERVERS_ENV] || ""); if (tools.length > 0 || !isDeferredServer) { return tools; } const core = global.core; core.warning(`[${serverName}] cached tool schema is empty for deferred server; refreshing from live gateway`); - for (let attempt = 1; attempt <= DEFERRED_TOOLS_LIST_MAX_ATTEMPTS; attempt++) { + for (let attempt = 1; attempt <= maxAttempts; attempt++) { try { const sessionId = await mcpInitialize(serverUrl, apiKey, serverName); await mcpNotifyInitialized(serverUrl, apiKey, sessionId, serverName); @@ -1175,12 +1180,12 @@ async function refreshDeferredToolsIfNeeded(tools, serverName, serverUrl, apiKey core.info(`[${serverName}] refreshed deferred tools cache with ${refreshed.length} tool(s)`); return refreshed; } - core.warning(`[${serverName}] live tools/list attempt ${attempt}/${DEFERRED_TOOLS_LIST_MAX_ATTEMPTS} returned 0 tools for deferred server`); + core.warning(`[${serverName}] live tools/list attempt ${attempt}/${maxAttempts} returned 0 tools for deferred server`); } catch (err) { - core.warning(`[${serverName}] deferred tools/list attempt ${attempt}/${DEFERRED_TOOLS_LIST_MAX_ATTEMPTS} failed: ${getErrorMessage(err)}`); + core.warning(`[${serverName}] deferred tools/list attempt ${attempt}/${maxAttempts} failed: ${getErrorMessage(err)}`); } - if (attempt < DEFERRED_TOOLS_LIST_MAX_ATTEMPTS) { - await new Promise(resolve => setTimeout(resolve, DEFERRED_TOOLS_LIST_RETRY_DELAY_MS)); + if (attempt < maxAttempts) { + await new Promise(resolve => setTimeout(resolve, retryDelayMs)); } } core.warning(`[${serverName}] deferred tools/list refresh exhausted retries; using cached empty schema`); diff --git a/actions/setup/js/mcp_cli_bridge.test.cjs b/actions/setup/js/mcp_cli_bridge.test.cjs index 0826845e033..37cb5caa630 100644 --- a/actions/setup/js/mcp_cli_bridge.test.cjs +++ b/actions/setup/js/mcp_cli_bridge.test.cjs @@ -311,10 +311,54 @@ describe("mcp_cli_bridge.cjs", () => { const port = typeof address === "object" && address ? address.port : 0; try { - const refreshed = await refreshDeferredToolsIfNeeded([], "awf-enclave", `http://127.0.0.1:${port}/mcp/awf-enclave`, "key", toolsFile); + const refreshed = await refreshDeferredToolsIfNeeded([], "awf-enclave", `http://127.0.0.1:${port}/mcp/awf-enclave`, "key", toolsFile, 2, 0); expect(refreshed).toEqual([{ name: "enclave_run_agent" }]); expect(toolsListCalls).toBe(2); - expect(global.core.warning).toHaveBeenCalledWith(expect.stringContaining("attempt 1/5 returned 0 tools")); + expect(global.core.warning).toHaveBeenCalledWith(expect.stringContaining("attempt 1/2 returned 0 tools")); + } finally { + await new Promise(resolve => server.close(resolve)); + fs.rmSync(tempDir, { recursive: true, force: true }); + } + }); + + it("returns cached tools after deferred refresh retries are exhausted", async () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "bridge-deferred-exhausted-")); + const toolsFile = path.join(tempDir, "awf-enclave.json"); + fs.writeFileSync(toolsFile, "[]", "utf8"); + let toolsListCalls = 0; + const cachedTools = []; + + const server = http.createServer((req, res) => { + let data = ""; + req.on("data", chunk => { + data += chunk; + }); + req.on("end", () => { + const parsed = JSON.parse(data || "{}"); + if (parsed.method === "initialize") { + res.writeHead(200, { "Content-Type": "application/json", "Mcp-Session-Id": "s1" }); + res.end(JSON.stringify({ jsonrpc: "2.0", id: parsed.id, result: {} })); + return; + } + if (parsed.method === "tools/list") { + toolsListCalls += 1; + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ jsonrpc: "2.0", id: parsed.id, result: { tools: [] } })); + return; + } + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ jsonrpc: "2.0", result: {} })); + }); + }); + await new Promise(resolve => server.listen(0, "127.0.0.1", resolve)); + const address = server.address(); + const port = typeof address === "object" && address ? address.port : 0; + + try { + const refreshed = await refreshDeferredToolsIfNeeded(cachedTools, "awf-enclave", `http://127.0.0.1:${port}/mcp/awf-enclave`, "key", toolsFile, 2, 0); + expect(refreshed).toBe(cachedTools); + expect(toolsListCalls).toBe(2); + expect(global.core.warning).toHaveBeenCalledWith(expect.stringContaining("refresh exhausted retries")); } finally { await new Promise(resolve => server.close(resolve)); fs.rmSync(tempDir, { recursive: true, force: true }); From 0386fdbbd0df6fb1b016e5939df0bdf514d15508 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 10 Sep 2026 10:42:57 +0000 Subject: [PATCH 10/10] Cache exhausted deferred refresh attempts Co-authored-by: lpcox <15877973+lpcox@users.noreply.github.com> --- actions/setup/js/mcp_cli_bridge.cjs | 11 ++++- actions/setup/js/mcp_cli_bridge.test.cjs | 51 ++++++++++++++++++++++++ 2 files changed, 61 insertions(+), 1 deletion(-) diff --git a/actions/setup/js/mcp_cli_bridge.cjs b/actions/setup/js/mcp_cli_bridge.cjs index 08f93d2ecde..7da806b0044 100644 --- a/actions/setup/js/mcp_cli_bridge.cjs +++ b/actions/setup/js/mcp_cli_bridge.cjs @@ -80,6 +80,8 @@ const AWF_ENCLAVE_SERVER_NAME = "awf-enclave"; const DEFERRED_SERVERS_ENV = "GH_AW_MCP_DEFERRED_SERVERS"; const DEFERRED_TOOLS_LIST_MAX_ATTEMPTS = 5; const DEFERRED_TOOLS_LIST_RETRY_DELAY_MS = 1000; +/** @type {Set} */ +const deferredToolsRefreshExhaustedServers = new Set(); // --------------------------------------------------------------------------- // Audit logging @@ -1139,7 +1141,8 @@ function isEnclaveBitBudgetExhausted(serverName, message) { * GH_AW_MCP_DEFERRED_SERVERS is also treated as deferred. These servers may * register after the wrapper is mounted, so their startup-time cache can * legitimately be empty. The refresh retries bounded attempts before falling - * back to the cached tools. + * back to the cached tools. If retries are exhausted, a process-local marker + * avoids repeating the same bounded retry loop again in this invocation. * * @param {Array<{name: string, description?: string, inputSchema?: {properties?: Record, required?: string[]}}>} tools * @param {string} serverName @@ -1155,6 +1158,10 @@ async function refreshDeferredToolsIfNeeded(tools, serverName, serverUrl, apiKey if (tools.length > 0 || !isDeferredServer) { return tools; } + if (deferredToolsRefreshExhaustedServers.has(serverName)) { + global.core.warning(`[${serverName}] deferred tools/list refresh already exhausted in this process; using cached schema`); + return tools; + } const core = global.core; core.warning(`[${serverName}] cached tool schema is empty for deferred server; refreshing from live gateway`); for (let attempt = 1; attempt <= maxAttempts; attempt++) { @@ -1177,6 +1184,7 @@ async function refreshDeferredToolsIfNeeded(tools, serverName, serverUrl, apiKey } catch (err) { core.warning(`[${serverName}] failed to update refreshed tools cache ${toolsFile}: ${getErrorMessage(err)}`); } + deferredToolsRefreshExhaustedServers.delete(serverName); core.info(`[${serverName}] refreshed deferred tools cache with ${refreshed.length} tool(s)`); return refreshed; } @@ -1188,6 +1196,7 @@ async function refreshDeferredToolsIfNeeded(tools, serverName, serverUrl, apiKey await new Promise(resolve => setTimeout(resolve, retryDelayMs)); } } + deferredToolsRefreshExhaustedServers.add(serverName); core.warning(`[${serverName}] deferred tools/list refresh exhausted retries; using cached empty schema`); return tools; } diff --git a/actions/setup/js/mcp_cli_bridge.test.cjs b/actions/setup/js/mcp_cli_bridge.test.cjs index 37cb5caa630..fda2d491ffa 100644 --- a/actions/setup/js/mcp_cli_bridge.test.cjs +++ b/actions/setup/js/mcp_cli_bridge.test.cjs @@ -365,6 +365,57 @@ describe("mcp_cli_bridge.cjs", () => { } }); + it("skips repeated deferred retry loops after exhaustion in the same process", async () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "bridge-deferred-skip-")); + const toolsFile = path.join(tempDir, "awf-enclave.json"); + fs.writeFileSync(toolsFile, "[]", "utf8"); + let toolsListCalls = 0; + const serverName = "deferred-test-server"; + const originalDeferred = process.env.GH_AW_MCP_DEFERRED_SERVERS; + process.env.GH_AW_MCP_DEFERRED_SERVERS = serverName; + + const server = http.createServer((req, res) => { + let data = ""; + req.on("data", chunk => { + data += chunk; + }); + req.on("end", () => { + const parsed = JSON.parse(data || "{}"); + if (parsed.method === "initialize") { + res.writeHead(200, { "Content-Type": "application/json", "Mcp-Session-Id": "s1" }); + res.end(JSON.stringify({ jsonrpc: "2.0", id: parsed.id, result: {} })); + return; + } + if (parsed.method === "tools/list") { + toolsListCalls += 1; + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ jsonrpc: "2.0", id: parsed.id, result: { tools: [] } })); + return; + } + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ jsonrpc: "2.0", result: {} })); + }); + }); + await new Promise(resolve => server.listen(0, "127.0.0.1", resolve)); + const address = server.address(); + const port = typeof address === "object" && address ? address.port : 0; + + try { + await refreshDeferredToolsIfNeeded([], serverName, `http://127.0.0.1:${port}/mcp/awf-enclave`, "key", toolsFile, 2, 0); + await refreshDeferredToolsIfNeeded([], serverName, `http://127.0.0.1:${port}/mcp/awf-enclave`, "key", toolsFile, 2, 0); + expect(toolsListCalls).toBe(2); + expect(global.core.warning).toHaveBeenCalledWith(expect.stringContaining("already exhausted in this process")); + } finally { + await new Promise(resolve => server.close(resolve)); + if (originalDeferred === undefined) { + delete process.env.GH_AW_MCP_DEFERRED_SERVERS; + } else { + process.env.GH_AW_MCP_DEFERRED_SERVERS = originalDeferred; + } + fs.rmSync(tempDir, { recursive: true, force: true }); + } + }); + it("allows zero-argument tools to proceed — only shows help when required fields are declared", () => { // Empty schema (zero-input custom tool) — must NOT show help; empty call is valid const emptySchemaTools = { inputSchema: { type: "object", properties: {}, additionalProperties: false } };