From 0b738c5b91c2650675c5dd8ba6a56fa4a8b63f89 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 2 Aug 2026 19:38:35 +0000 Subject: [PATCH 1/3] Initial plan From 24ba62daf58564e74d0788ed45c66c239e1c38bf Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 2 Aug 2026 20:07:04 +0000 Subject: [PATCH 2/3] Fix read-tool workspace permission scoping regression (#49836) Always allow read requests for paths at or under GITHUB_WORKSPACE in buildCopilotSDKPermissionHandler. Previously, workflows with any tool restriction but no explicit read grant would deny read($GITHUB_WORKSPACE), exhausting the denial threshold (3/3) and killing the run. - copilot_sdk_permissions.cjs: add workspace-root allowlist in case "read" - copilot_sdk_driver.test.cjs: add regression test + update affected tests Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- actions/setup/js/copilot_sdk_driver.test.cjs | 129 ++++++++++++++++--- actions/setup/js/copilot_sdk_permissions.cjs | 10 ++ 2 files changed, 118 insertions(+), 21 deletions(-) diff --git a/actions/setup/js/copilot_sdk_driver.test.cjs b/actions/setup/js/copilot_sdk_driver.test.cjs index 63fbe3bb633..146e6eb050e 100644 --- a/actions/setup/js/copilot_sdk_driver.test.cjs +++ b/actions/setup/js/copilot_sdk_driver.test.cjs @@ -1215,17 +1215,83 @@ describe("copilot_sdk_driver.cjs", () => { // Relative paths that match the pattern must still work. expect(onPermissionRequest({ kind: "read", path: "pkg/workflow/compiler.go", intention: "" })).toEqual({ kind: "approve-once" }); - // Files outside pkg/ or outside the workspace root must be denied. - expect(onPermissionRequest({ kind: "read", path: "/home/runner/work/gh-aw/gh-aw/AGENTS.md", intention: "" })).toEqual({ + // Files within the workspace root are always allowed (workspace-root allowlist). + // Even files outside pkg/ must be readable since they are part of the checkout. + expect(onPermissionRequest({ kind: "read", path: "/home/runner/work/gh-aw/gh-aw/AGENTS.md", intention: "" })).toEqual({ kind: "approve-once" }); + expect(onPermissionRequest({ kind: "read", path: "/home/runner/work/gh-aw/gh-aw", intention: "" })).toEqual({ kind: "approve-once" }); + // Files outside the workspace root must be denied. + expect(onPermissionRequest({ kind: "read", path: "/etc/passwd", intention: "" })).toEqual({ + kind: "reject", + feedback: "Tool invocation is not allowed by workflow tool permissions.", + }); + // A path outside the workspace that contains /pkg/ must not be permitted. + expect(onPermissionRequest({ kind: "read", path: "/other/workspace/pkg/workflow/file.go", intention: "" })).toEqual({ kind: "reject", feedback: "Tool invocation is not allowed by workflow tool permissions.", }); + } finally { + if (prevWorkspace === undefined) { + delete process.env.GITHUB_WORKSPACE; + } else { + process.env.GITHUB_WORKSPACE = prevWorkspace; + } + } + }); + + it("always allows read of workspace root and its subdirectories for read-only workflows (regression: #49836)", async () => { + // Regression test: a workflow with only specific tool restrictions (e.g., github MCP + specific + // bash commands) must never have read($GITHUB_WORKSPACE) denied. Previously, any workflow with + // partial tool restrictions and no explicit read grant would deny workspace reads, causing + // guard.tool_denials_exceeded after 3 attempts and killing the run. + const prevWorkspace = process.env.GITHUB_WORKSPACE; + process.env.GITHUB_WORKSPACE = "/home/runner/work/gh-aw/gh-aw"; + try { + const disconnect = vi.fn().mockResolvedValue(undefined); + const stop = vi.fn().mockResolvedValue(undefined); + const createSession = vi.fn().mockResolvedValue({ + sessionId: "session-workspace-root-always-readable", + on: () => {}, + sendAndWait: vi.fn().mockResolvedValue({ data: { content: "ok" } }), + disconnect, + }); + class FakeCopilotClient { + start = vi.fn().mockResolvedValue(undefined); + createSession = createSession; + stop = stop; + } + + await runWithCopilotSDK({ + sdkUri: "http://127.0.0.1:3002", + prompt: "test prompt", + logger: () => {}, + // Read-only workflow: only github MCP + restricted bash, no explicit read grant. + permissionConfig: { + allowedTools: ["github", 'shell(find . -name "*_test.go" -type f)', "shell(cat **/*_test.go)", 'shell(grep -r "func Test" . --include="*_test.go")', "shell(go test -v ./...)", "shell(wc -l **/*_test.go)", "shell(gh:*)"], + }, + sdkModule: { + CopilotClient: FakeCopilotClient, + RuntimeConnection: { forUri: vi.fn(() => ({})) }, + approveAll: () => ({ kind: "approve-once" }), + }, + }); + + const sessionConfig = createSession.mock.calls[0][0]; + const onPermissionRequest = sessionConfig.onPermissionRequest; + + // The workspace root itself must always be readable. + expect(onPermissionRequest({ kind: "read", path: "/home/runner/work/gh-aw/gh-aw", intention: "" })).toEqual({ kind: "approve-once" }); + // Any subdirectory under the workspace must be readable. + expect(onPermissionRequest({ kind: "read", path: "/home/runner/work/gh-aw/gh-aw/pkg/workflow", intention: "" })).toEqual({ kind: "approve-once" }); + // Any file under the workspace must be readable. + expect(onPermissionRequest({ kind: "read", path: "/home/runner/work/gh-aw/gh-aw/AGENTS.md", intention: "" })).toEqual({ kind: "approve-once" }); + expect(onPermissionRequest({ kind: "read", path: "/home/runner/work/gh-aw/gh-aw/pkg/workflow/copilot_engine_tools.go", intention: "" })).toEqual({ kind: "approve-once" }); + + // Paths outside the workspace root must still be denied. expect(onPermissionRequest({ kind: "read", path: "/etc/passwd", intention: "" })).toEqual({ kind: "reject", feedback: "Tool invocation is not allowed by workflow tool permissions.", }); - // A path outside the workspace that contains /pkg/ must not be permitted. - expect(onPermissionRequest({ kind: "read", path: "/other/workspace/pkg/workflow/file.go", intention: "" })).toEqual({ + expect(onPermissionRequest({ kind: "read", path: "/home/runner/work/other-repo/secret.txt", intention: "" })).toEqual({ kind: "reject", feedback: "Tool invocation is not allowed by workflow tool permissions.", }); @@ -1801,24 +1867,45 @@ for f in $FILES; do wc -l "/home/runner/work/gh-aw/gh-aw/pkg/workflow/$f"; done` expect(result).toEqual({ kind: "approve-once" }); }); - it("requires explicit read permission for AGENTS.md and SKILL.md reads", async () => { - const denied = await makePermissionHandlerViaSDK(["shell(ls)"]); - expect(denied({ kind: "read", path: "/home/runner/work/gh-aw/gh-aw/AGENTS.md" })).toEqual({ - kind: "reject", - feedback: "Tool invocation is not allowed by workflow tool permissions.", - }); - expect(denied({ kind: "read", path: "/home/runner/work/gh-aw/gh-aw/SKILL.md" })).toEqual({ - kind: "reject", - feedback: "Tool invocation is not allowed by workflow tool permissions.", - }); + it("workspace files are always readable; only non-workspace paths require explicit read permission", async () => { + // Regression fix (#49836): workspace root and its contents are always readable regardless + // of tool restrictions. Only paths outside GITHUB_WORKSPACE require an explicit read grant. + const prevWorkspace = process.env.GITHUB_WORKSPACE; + process.env.GITHUB_WORKSPACE = "/home/runner/work/gh-aw/gh-aw"; + try { + const deniedOutsideWorkspace = await makePermissionHandlerViaSDK(["shell(ls)"]); + // Files inside the workspace are always readable — no explicit grant needed. + expect(deniedOutsideWorkspace({ kind: "read", path: "/home/runner/work/gh-aw/gh-aw/AGENTS.md" })).toEqual({ + kind: "approve-once", + }); + expect(deniedOutsideWorkspace({ kind: "read", path: "/home/runner/work/gh-aw/gh-aw/SKILL.md" })).toEqual({ + kind: "approve-once", + }); + // Files outside the workspace still require an explicit read grant. + expect(deniedOutsideWorkspace({ kind: "read", path: "/etc/passwd" })).toEqual({ + kind: "reject", + feedback: "Tool invocation is not allowed by workflow tool permissions.", + }); + expect(deniedOutsideWorkspace({ kind: "read", path: "/home/runner/other-repo/secret.txt" })).toEqual({ + kind: "reject", + feedback: "Tool invocation is not allowed by workflow tool permissions.", + }); - const allowed = await makePermissionHandlerViaSDK(["read"]); - expect(allowed({ kind: "read", path: "/home/runner/work/gh-aw/gh-aw/AGENTS.md" })).toEqual({ - kind: "approve-once", - }); - expect(allowed({ kind: "read", path: "/home/runner/work/gh-aw/gh-aw/SKILL.md" })).toEqual({ - kind: "approve-once", - }); + const allowed = await makePermissionHandlerViaSDK(["read"]); + // With an explicit read grant, all paths are readable. + expect(allowed({ kind: "read", path: "/home/runner/work/gh-aw/gh-aw/AGENTS.md" })).toEqual({ + kind: "approve-once", + }); + expect(allowed({ kind: "read", path: "/home/runner/work/gh-aw/gh-aw/SKILL.md" })).toEqual({ + kind: "approve-once", + }); + } finally { + if (prevWorkspace === undefined) { + delete process.env.GITHUB_WORKSPACE; + } else { + process.env.GITHUB_WORKSPACE = prevWorkspace; + } + } }); it("denies issue-37538 commands when workflow only allows jq shell usage", async () => { diff --git a/actions/setup/js/copilot_sdk_permissions.cjs b/actions/setup/js/copilot_sdk_permissions.cjs index 68e41ffdb2c..cf4dacb1698 100644 --- a/actions/setup/js/copilot_sdk_permissions.cjs +++ b/actions/setup/js/copilot_sdk_permissions.cjs @@ -352,6 +352,16 @@ function buildCopilotSDKPermissionHandler(permissionConfig, approveAll, logOptio return allowedToolEntries.has("write"); case "read": // Any read grant (read, read(...), read:*) is path-agnostic in Copilot SDK. + // Always allow reads for paths at or under the workspace root (GITHUB_WORKSPACE). + // Every workflow runs inside its own checkout and must be able to read its source tree + // regardless of any narrower tool-permission scoping configured elsewhere. + if (logOptions?.workspaceRoot && typeof request.path === "string" && request.path.length > 0) { + const normalizedWorkspace = normalizePermissionPath(logOptions.workspaceRoot); + const normalizedPath = normalizePermissionPath(request.path); + if (normalizedPath === normalizedWorkspace || normalizedPath.startsWith(normalizedWorkspace + "/")) { + return true; + } + } return hasReadGrant || allowedToolEntries.has("shell") || isReadPathAllowedByShellRules(request.path, readablePathPatterns, logOptions?.workspaceRoot); case "url": return allowedToolEntries.has("web_fetch"); From 19fa21187f4d7d31c0e20b25a5637a83016f8008 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 2 Aug 2026 21:05:22 +0000 Subject: [PATCH 3/3] Fix path traversal in workspace read check using path.resolve + path.relative Replace string-prefix check with path.resolve + path.relative to correctly: - Reject traversal paths (e.g. workspace/../../../../etc/passwd) - Approve relative paths (e.g. AGENTS.md) resolved inside workspace Also update tests: - Add traversal and relative-path assertions to regression test - Add traversal and relative-path assertions to unit permission handler test - Update original scoped-handler test to use /etc/passwd instead of a.txt (relative paths now correctly resolve inside workspace and are approved)" Co-authored-by: gh-aw-bot <259018956+gh-aw-bot@users.noreply.github.com> --- actions/setup/js/copilot_sdk_driver.test.cjs | 29 +++++++++++++++++++- actions/setup/js/copilot_sdk_permissions.cjs | 11 ++++++-- 2 files changed, 36 insertions(+), 4 deletions(-) diff --git a/actions/setup/js/copilot_sdk_driver.test.cjs b/actions/setup/js/copilot_sdk_driver.test.cjs index 146e6eb050e..063830609da 100644 --- a/actions/setup/js/copilot_sdk_driver.test.cjs +++ b/actions/setup/js/copilot_sdk_driver.test.cjs @@ -1007,7 +1007,8 @@ describe("copilot_sdk_driver.cjs", () => { expect(onPermissionRequest({ kind: "mcp", serverName: "github", toolName: "get_file_contents" })).toEqual({ kind: "approve-once" }); expect(onPermissionRequest({ kind: "url", url: "https://example.com" })).toEqual({ kind: "approve-once" }); expect(onPermissionRequest({ kind: "write", fileName: "a.txt", diff: "", intention: "" })).toEqual({ kind: "approve-once" }); - expect(onPermissionRequest({ kind: "read", path: "a.txt", intention: "" })).toEqual({ + // Reads of paths outside the workspace are denied without an explicit read grant. + expect(onPermissionRequest({ kind: "read", path: "/etc/passwd", intention: "" })).toEqual({ kind: "reject", feedback: "Tool invocation is not allowed by workflow tool permissions.", }); @@ -1286,6 +1287,20 @@ describe("copilot_sdk_driver.cjs", () => { expect(onPermissionRequest({ kind: "read", path: "/home/runner/work/gh-aw/gh-aw/AGENTS.md", intention: "" })).toEqual({ kind: "approve-once" }); expect(onPermissionRequest({ kind: "read", path: "/home/runner/work/gh-aw/gh-aw/pkg/workflow/copilot_engine_tools.go", intention: "" })).toEqual({ kind: "approve-once" }); + // Relative paths must be resolved inside the workspace and approved. + expect(onPermissionRequest({ kind: "read", path: "AGENTS.md", intention: "" })).toEqual({ kind: "approve-once" }); + expect(onPermissionRequest({ kind: "read", path: "pkg/workflow/file.go", intention: "" })).toEqual({ kind: "approve-once" }); + + // Path traversal attempts must be rejected even when they start with the workspace prefix. + expect(onPermissionRequest({ kind: "read", path: "/home/runner/work/gh-aw/gh-aw/../../../../etc/passwd", intention: "" })).toEqual({ + kind: "reject", + feedback: "Tool invocation is not allowed by workflow tool permissions.", + }); + expect(onPermissionRequest({ kind: "read", path: "/home/runner/work/gh-aw/gh-aw/../../../other-repo/secret.txt", intention: "" })).toEqual({ + kind: "reject", + feedback: "Tool invocation is not allowed by workflow tool permissions.", + }); + // Paths outside the workspace root must still be denied. expect(onPermissionRequest({ kind: "read", path: "/etc/passwd", intention: "" })).toEqual({ kind: "reject", @@ -1881,6 +1896,18 @@ for f in $FILES; do wc -l "/home/runner/work/gh-aw/gh-aw/pkg/workflow/$f"; done` expect(deniedOutsideWorkspace({ kind: "read", path: "/home/runner/work/gh-aw/gh-aw/SKILL.md" })).toEqual({ kind: "approve-once", }); + // Relative paths must be resolved inside the workspace and approved. + expect(deniedOutsideWorkspace({ kind: "read", path: "AGENTS.md" })).toEqual({ + kind: "approve-once", + }); + expect(deniedOutsideWorkspace({ kind: "read", path: "pkg/workflow/file.go" })).toEqual({ + kind: "approve-once", + }); + // Path traversal attempts must be rejected even when they start with the workspace prefix. + expect(deniedOutsideWorkspace({ kind: "read", path: "/home/runner/work/gh-aw/gh-aw/../../../../etc/passwd" })).toEqual({ + kind: "reject", + feedback: "Tool invocation is not allowed by workflow tool permissions.", + }); // Files outside the workspace still require an explicit read grant. expect(deniedOutsideWorkspace({ kind: "read", path: "/etc/passwd" })).toEqual({ kind: "reject", diff --git a/actions/setup/js/copilot_sdk_permissions.cjs b/actions/setup/js/copilot_sdk_permissions.cjs index cf4dacb1698..a8dd6f9d4e5 100644 --- a/actions/setup/js/copilot_sdk_permissions.cjs +++ b/actions/setup/js/copilot_sdk_permissions.cjs @@ -355,10 +355,15 @@ function buildCopilotSDKPermissionHandler(permissionConfig, approveAll, logOptio // Always allow reads for paths at or under the workspace root (GITHUB_WORKSPACE). // Every workflow runs inside its own checkout and must be able to read its source tree // regardless of any narrower tool-permission scoping configured elsewhere. + // + // Use path.resolve + path.relative for containment rather than a string-prefix check so + // that ".." traversal paths (e.g. workspace/../../../../etc/passwd) are rejected and + // relative paths (e.g. "AGENTS.md") are correctly resolved inside the workspace. if (logOptions?.workspaceRoot && typeof request.path === "string" && request.path.length > 0) { - const normalizedWorkspace = normalizePermissionPath(logOptions.workspaceRoot); - const normalizedPath = normalizePermissionPath(request.path); - if (normalizedPath === normalizedWorkspace || normalizedPath.startsWith(normalizedWorkspace + "/")) { + const resolvedWorkspace = path.resolve(logOptions.workspaceRoot); + const resolvedPath = path.isAbsolute(request.path) ? path.resolve(request.path) : path.resolve(resolvedWorkspace, request.path); + const rel = path.relative(resolvedWorkspace, resolvedPath); + if (rel === "" || (!rel.startsWith("..") && !path.isAbsolute(rel))) { return true; } }