diff --git a/.changeset/patch-agentic-workflows-backend-ownership.md b/.changeset/patch-agentic-workflows-backend-ownership.md new file mode 100644 index 00000000000..ffefdb0930c --- /dev/null +++ b/.changeset/patch-agentic-workflows-backend-ownership.md @@ -0,0 +1,5 @@ +--- +"gh-aw": patch +--- + +Run the built-in agentic-workflows MCP backend as the runner so private downloaded logs remain readable by artifact secret redaction, without loosening file permissions or bypassing fail-closed cleanup. diff --git a/actions/setup/js/start_mcp_gateway.cjs b/actions/setup/js/start_mcp_gateway.cjs index 35805800e3c..d9fea7dc3e6 100644 --- a/actions/setup/js/start_mcp_gateway.cjs +++ b/actions/setup/js/start_mcp_gateway.cjs @@ -933,6 +933,7 @@ async function main() { core.setFailed("ERROR: Configuration is missing required 'gateway' section"); return; } + applyAgenticWorkflowsRunnerIdentity(configObj); applyGatewayRuntimeTimeouts(configObj, process.env); mcpConfig = JSON.stringify(configObj); if (!("port" in gw) || gw.port == null) { @@ -1480,7 +1481,40 @@ if (require.main === module) { }); } +/** + * Backend containers do not inherit the gateway container's Docker user. + * Keep private downloaded logs runner-owned so artifact redaction can read them. + * @param {Record} config + * @param {number | undefined} [uid] + * @param {number | undefined} [gid] + */ +function applyAgenticWorkflowsRunnerIdentity(config, uid = process.getuid?.(), gid = process.getgid?.()) { + const servers = config.mcpServers; + if (!servers || typeof servers !== "object" || !("agenticworkflows" in servers)) return; + const server = servers.agenticworkflows; + if (!server || typeof server !== "object" || !("container" in server) || typeof server.container !== "string" || !server.container) return; + const args = "args" in server ? server.args : []; + if (!Array.isArray(args) || !args.every(arg => typeof arg === "string")) { + throw new Error("Invalid agenticworkflows container arguments"); + } + // Docker parses any suffix after -u as a username or UID, including -uroot. + if (args.some(arg => arg === "--user" || arg === "-u" || arg.startsWith("--user=") || (arg.startsWith("-u") && !arg.startsWith("--")))) return; + if (!Number.isSafeInteger(uid) || !Number.isSafeInteger(gid) || uid === undefined || gid === undefined || uid < 0 || gid < 0) { + throw new Error("Runner identity is required for the agenticworkflows container"); + } + const env = "env" in server ? server.env : {}; + if (!env || typeof env !== "object" || Array.isArray(env)) { + throw new Error("Invalid agenticworkflows container environment"); + } + Object.assign(server, { + args: [...args, "--user", `${uid}:${gid}`], + // /tmp is private to this backend container; /root is not writable as the runner. + env: { HOME: "/tmp", ...env }, + }); +} + module.exports = { + applyAgenticWorkflowsRunnerIdentity, applyOTLPIgnoreIfMissing, clearGatewayStartupMarker, createGatewayStderrPath, diff --git a/actions/setup/js/start_mcp_gateway.test.cjs b/actions/setup/js/start_mcp_gateway.test.cjs index 3b03f4a03ff..40b0c8fd3a4 100644 --- a/actions/setup/js/start_mcp_gateway.test.cjs +++ b/actions/setup/js/start_mcp_gateway.test.cjs @@ -4,6 +4,7 @@ import path from "path"; import http from "http"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { + applyAgenticWorkflowsRunnerIdentity, applyOTLPIgnoreIfMissing, clearGatewayStartupMarker, createGatewayStderrPath, @@ -35,6 +36,78 @@ import { } from "./start_mcp_gateway.cjs"; import { writeMcpConfiguration } from "./mcp_configuration.cjs"; +describe("agenticworkflows backend ownership", () => { + it.each(["localhost/gh-aw:dev", "alpine:3.24"])("uses the runner identity without widening private artifact modes (%s)", container => { + const env = { GITHUB_TOKEN: "${GITHUB_TOKEN}", GITHUB_ACTOR: "${GITHUB_ACTOR}" }; + const backend = { container, args: ["--network", "host", "-w", "/workspace"], env }; + const other = { container: "example/server", args: [] }; + const config = { mcpServers: { agenticworkflows: backend, other } }; + applyAgenticWorkflowsRunnerIdentity(config, 1001, 123); + expect(backend.args).toEqual(["--network", "host", "-w", "/workspace", "--user", "1001:123"]); + expect(backend.env).toEqual({ HOME: "/tmp", ...env }); + expect(other).toEqual({ container: "example/server", args: [] }); + applyAgenticWorkflowsRunnerIdentity(config, 1001, 123); + expect(backend.args.filter(arg => arg === "--user")).toHaveLength(1); + }); + + it.each([["--user", "42:43"], ["--user=42:43"], ["-u", "42:43"], ["-u42:43"], ["--user", "app:staff"], ["--user=app:staff"], ["-u", "app:staff"], ["-uroot"], ["-uapp:staff"], ["-u=app:staff"]])( + "preserves an explicitly configured container user: %j", + (...args) => { + const backend = { container: "example/server", args, env: { HOME: "/custom" } }; + const original = structuredClone(backend); + applyAgenticWorkflowsRunnerIdentity({ mcpServers: { agenticworkflows: backend } }, 1001, 123); + applyAgenticWorkflowsRunnerIdentity({ mcpServers: { agenticworkflows: backend } }, 1001, 123); + expect(backend).toEqual(original); + } + ); + + it.each(["-update", "-unshare"])("preserves %s as Docker's attached -u username, not an unrelated flag", arg => { + const backend = { container: "example/server", args: [arg], env: { HOME: "/custom" } }; + const original = structuredClone(backend); + applyAgenticWorkflowsRunnerIdentity({ mcpServers: { agenticworkflows: backend } }, 1001, 123); + expect(backend).toEqual(original); + }); + + it("does not confuse --userns with an explicit container user", () => { + const backend = { container: "example/server", args: ["--userns=host"], env: { HOME: "/custom" } }; + applyAgenticWorkflowsRunnerIdentity({ mcpServers: { agenticworkflows: backend } }, 1001, 123); + expect(backend).toEqual({ container: "example/server", args: ["--userns=host", "--user", "1001:123"], env: { HOME: "/custom" } }); + }); + + it("preserves a configured HOME and supports default container arguments", () => { + const backend = { container: "alpine:3.24", env: { HOME: "/custom" } }; + applyAgenticWorkflowsRunnerIdentity({ mcpServers: { agenticworkflows: backend } }, 1001, 123); + expect(backend).toEqual({ container: "alpine:3.24", args: ["--user", "1001:123"], env: { HOME: "/custom" } }); + }); + + it.each([{}, { mcpServers: {} }, { mcpServers: { agenticworkflows: { url: "https://example.com/mcp" } } }])("leaves absent and HTTP backends unchanged", config => { + const original = structuredClone(config); + applyAgenticWorkflowsRunnerIdentity(config, 1001, 123); + expect(config).toEqual(original); + }); + + it.each([ + [-1, 123], + [1001, -1], + [NaN, 123], + [1001, Infinity], + [1.5, 123], + ])("rejects invalid runner identities (%s, %s)", (uid, gid) => { + const backend = { container: "alpine:3.24" }; + expect(() => applyAgenticWorkflowsRunnerIdentity({ mcpServers: { agenticworkflows: backend } }, uid, gid)).toThrow("Runner identity is required"); + expect(backend).toEqual({ container: "alpine:3.24" }); + }); + + it.each([{ args: "invalid" }, { args: [42] }, { env: [] }, { env: null }])("rejects malformed backend configuration without publishing it", fields => { + expect(() => applyAgenticWorkflowsRunnerIdentity({ mcpServers: { agenticworkflows: { container: "alpine:3.24", ...fields } } }, 1001, 123)).toThrow(/Invalid agenticworkflows container/); + }); + + it("applies identity before serializing the configuration sent to the gateway", () => { + const source = fs.readFileSync(new URL("./start_mcp_gateway.cjs", import.meta.url), "utf8"); + expect(source.indexOf("applyAgenticWorkflowsRunnerIdentity(configObj)")).toBeLessThan(source.indexOf("mcpConfig = JSON.stringify(configObj)")); + }); +}); + describe("start_mcp_gateway logging", () => { it("records only a sanitized configuration snapshot before launching the gateway", () => { const source = fs.readFileSync(new URL("./start_mcp_gateway.cjs", import.meta.url), "utf8"); diff --git a/docs/src/content/docs/reference/tools.md b/docs/src/content/docs/reference/tools.md index 32b42dc102f..3efbf1d2f41 100644 --- a/docs/src/content/docs/reference/tools.md +++ b/docs/src/content/docs/reference/tools.md @@ -200,6 +200,8 @@ tools: See [GH-AW as an MCP Server](/gh-aw/reference/gh-aw-as-mcp-server/) for available operations. +The built-in container runs with the runner's UID and GID unless a container user is explicitly configured. This keeps downloaded logs runner-owned without widening their owner-only permissions, so the post-agent secret-redaction step can read them. Its default `HOME` is the backend container's ephemeral `/tmp`, not `/root`. Redaction failures still remove unsanitized sources and fail the step. + Workflow-writing tools (`add`, `update`, `fix`, and `compile`, including dry-run compilation) are disabled by default. Trusted workflows can opt in at server startup: ```aw wrap