Skip to content

Fix read-tool workspace permission scoping regression (daily workflows killed by denial threshold) - #49840

Merged
pelikhan merged 4 commits into
mainfrom
copilot/aw-failures-fix-read-tool-permission-scoping
Aug 2, 2026
Merged

Fix read-tool workspace permission scoping regression (daily workflows killed by denial threshold)#49840
pelikhan merged 4 commits into
mainfrom
copilot/aw-failures-fix-read-tool-permission-scoping

Conversation

Copilot AI commented Aug 2, 2026

Copy link
Copy Markdown
Contributor

Workflows with any tool restriction but no explicit read grant had read($GITHUB_WORKSPACE) silently denied by the SDK permission handler, exhausting the 3-denial cap and aborting the run. Reproduced identically in two unrelated scheduled workflows (Daily Testify, Linter Miner) within 30 minutes on 2026-08-02.

Root cause

buildCopilotSDKPermissionHandler in copilot_sdk_permissions.cjs only approved read requests when either an explicit read grant was present, shell wildcard was granted, or the path matched patterns derived from specific cat/ls/xargs shell rules. Workflows using only MCP tools (e.g., github) or a restricted bash allowlist had no path to approve reads of their own checkout.

Changes

  • copilot_sdk_permissions.cjs — Add a workspace-root early-return at the top of case "read" in isAllowed(): if workspaceRoot is set and the requested path is at or under it, approve unconditionally. Paths outside GITHUB_WORKSPACE are unaffected.
// Before
case "read":
  return hasReadGrant || allowedToolEntries.has("shell") || isReadPathAllowedByShellRules(...);

// After
case "read":
  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(...);
  • copilot_sdk_driver.test.cjs — Add regression test reproducing the exact failing scenario (github MCP + restricted bash, no read grant → workspace reads must be approved). Update two existing tests whose assertions reflected the now-corrected behavior.

Run details: https://github.com/github/gh-aw/actions/runs/30766084787

Generated by 👨‍🍳 PR Sous Chef · gpt54 · 5.13 AIC · ⌖ 5.72 AIC · ⊞ 5.9K ·
Comment /souschef to run again

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>
Copilot AI changed the title [WIP] Fix read-tool permission scoping regression Fix read-tool workspace permission scoping regression (daily workflows killed by denial threshold) Aug 2, 2026
Copilot AI requested a review from pelikhan August 2, 2026 20:08
@pelikhan
pelikhan marked this pull request as ready for review August 2, 2026 20:26
Copilot AI review requested due to automatic review settings August 2, 2026 20:26
@github-actions

github-actions Bot commented Aug 2, 2026

Copy link
Copy Markdown
Contributor

Test Quality Sentinel completed test quality analysis.

@github-actions

github-actions Bot commented Aug 2, 2026

Copy link
Copy Markdown
Contributor

🧠 Matt Pocock Skills Reviewer has completed the skills-based review. ✅

@github-actions

github-actions Bot commented Aug 2, 2026

Copy link
Copy Markdown
Contributor

Design Decision Gate 🏗️ completed the design decision gate check.

No ADR enforcement needed: PR does not have the 'implementation' label and has ≤100 new lines of code in business logic directories (0 additions detected in default business logic directories).

@github-actions

github-actions Bot commented Aug 2, 2026

Copy link
Copy Markdown
Contributor

⚠️ PR Code Quality Reviewer failed during code quality review.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Fixes workspace read denials that prematurely terminate restricted workflows.

Changes:

  • Allows SDK reads within GITHUB_WORKSPACE.
  • Adds and updates regression tests for workspace-read behavior.
Show a summary per file
File Description
actions/setup/js/copilot_sdk_permissions.cjs Adds workspace read authorization.
actions/setup/js/copilot_sdk_driver.test.cjs Tests workspace and external-path decisions.

Review details

Tip

Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

  • Files reviewed: 2/2 changed files
  • Comments generated: 1
  • Review effort level: Balanced

Comment on lines +359 to +363
const normalizedWorkspace = normalizePermissionPath(logOptions.workspaceRoot);
const normalizedPath = normalizePermissionPath(request.path);
if (normalizedPath === normalizedWorkspace || normalizedPath.startsWith(normalizedWorkspace + "/")) {
return true;
}

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧵 Reviewed using Impeccable skills by Impeccable Skills Reviewer · sonnet46 · 29.5 AIC · ⌖ 15 AIC · ⊞ 5.4K

const normalizedWorkspace = normalizePermissionPath(logOptions.workspaceRoot);
const normalizedPath = normalizePermissionPath(request.path);
if (normalizedPath === normalizedWorkspace || normalizedPath.startsWith(normalizedWorkspace + "/")) {
return true;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Security: path-traversal bypass — workspace containment check can be defeated with ../ segments

normalizePermissionPath only strips trailing slashes and normalizes backslashes; it does not resolve .. components. A crafted path like /home/runner/work/gh-aw/gh-aw/../../../etc/passwd passes normalizePermissionPath unchanged and still starts with the workspace prefix, so the early return true fires for a file well outside the checkout.

// Proof
normalizePermissionPath("/home/runner/work/gh-aw/gh-aw/../../../etc/passwd")
// → "/home/runner/work/gh-aw/gh-aw/../../../etc/passwd"
// .startsWith("/home/runner/work/gh-aw/gh-aw/") === true  ← incorrectly approved

Fix: resolve both paths with path.posix.normalize before the prefix check so .. segments are collapsed:

const { posix } = require("path");
// inside case "read":
if (logOptions?.workspaceRoot && typeof request.path === "string" && request.path.length > 0) {
  const normalizedWorkspace = posix.normalize(normalizePermissionPath(logOptions.workspaceRoot));
  const normalizedPath = posix.normalize(normalizePermissionPath(request.path));
  if (normalizedPath === normalizedWorkspace || normalizedPath.startsWith(normalizedWorkspace + "/")) {
    return true;
  }
}

Add a corresponding test case:

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." });

@copilot please address this.

@github-actions

github-actions Bot commented Aug 2, 2026

Copy link
Copy Markdown
Contributor

🧪 Test Quality Sentinel Report

Test Quality Score: 90/100 — Excellent

Analyzed 2 test(s): 2 design, 0 implementation, 0 violation(s).

📊 Metrics (2 tests)
Metric Value
Analyzed 2 (JavaScript: 2)
✅ Design 2 (100%)
⚠️ Implementation 0 (0%)
Edge/error coverage 2/2 (100%)
Duplicate clusters 0
Inflation YES (10.8:1, exceeds 2:1 threshold)
🚨 Violations 0
Test File Classification Assertions
always allows read of workspace root and its subdirectories for read-only workflows (regression: #49836) copilot_sdk_driver.test.cjs:1241 behavioral_contract, high_value, design_test 6
workspace files are always readable; only non-workspace paths require explicit read permission (modified) copilot_sdk_driver.test.cjs:1870 behavioral_contract, high_value, design_test 8
📈 Test Inflation Analysis

Test file: copilot_sdk_driver.test.cjs (+108 lines)
Production file: copilot_sdk_permissions.cjs (+10 lines)
Ratio: 10.8:1 (exceeds 2:1 threshold)

Justification: The new regression test is comprehensive and documents the critical design contract that workspace files must always be readable. This fixes bug #49836 which killed daily workflows when permission denials exceeded a threshold. The detailed test coverage is intentional and justified.

Verdict

passed. 0% implementation tests (threshold: 30%). Both tests verify design contracts and edge/error boundaries. Test inflation is justified for regression testing a critical bug.

🧪 Test quality analysis by Test Quality Sentinel · haiku45 · 19.4 AIC · ⌖ 5.86 AIC · ⊞ 8.5K ·
Comment /review to run again

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ Test Quality Sentinel: 90/100. 0% implementation tests (threshold: 30%). High-quality regression tests that verify the critical design contract.

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Skills-Based Review 🧠

Applied /diagnosing-bugs and /tdd — requesting changes on one security correctness issue.

📋 Key Themes & Highlights

Key Themes

  • Path traversal bypass (security/correctness): normalizePermissionPath does not collapse .. segments, so ../../etc/passwd-style paths incorrectly pass the workspace prefix check. This is a security regression introduced by the fix itself — the bypass vector did not exist before this PR.
  • Missing boundary-condition test: No test covers the GITHUB_WORKSPACE-unset path, leaving the fallback contract unpinned.

Positive Highlights

  • ✅ Root cause correctly identified and fixed at the right layer (buildCopilotSDKPermissionHandler, not the call site).
  • ✅ Regression test reproduces the exact production failure scenario (github MCP + restricted bash, no read grant).
  • try/finally env-restore pattern is applied consistently across all three modified test cases.
  • ✅ Existing test names updated to reflect the new semantics — good specification hygiene.

🧠 Reviewed using Matt Pocock's skills by Matt Pocock Skills Reviewer · sonnet46 · 50.6 AIC · ⌖ 10.1 AIC · ⊞ 7.1K
Comment /matt to run again

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 + "/")) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[/diagnosing-bugs] Path traversal bypass: normalizePermissionPath strips trailing slashes but does not resolve .. segments. A path like /home/runner/work/gh-aw/gh-aw/../../../etc/passwd passes the startsWith workspace check yet escapes the workspace entirely.

💡 Fix: resolve dot-segments before comparing

Use path.resolve (already available in Node.js) instead of the string-only comparison:

const nodePath = require('path');
// ...
if (logOptions?.workspaceRoot && typeof request.path === 'string' && request.path.length > 0) {
  const resolvedWorkspace = nodePath.resolve(logOptions.workspaceRoot);
  const resolvedPath = nodePath.resolve(request.path);
  if (resolvedPath === resolvedWorkspace || resolvedPath.startsWith(resolvedWorkspace + '/')) {
    return true;
  }
}

Proof of current bypass:

// normalizePermissionPath does NOT collapse ".."
normalize('/home/runner/work/gh-aw/gh-aw/../../../etc/passwd')
// => '/home/runner/work/gh-aw/gh-aw/../../../etc/passwd'
// .startsWith('/home/runner/work/gh-aw/gh-aw/') === true  ⚠️

This is a security regression — the workspace allowlist must be traversal-safe.

@copilot please address this.

await runWithCopilotSDK({
sdkUri: "http://127.0.0.1:3002",
prompt: "test prompt",
logger: () => {},

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[/tdd] The regression test for #49836 does not test the case where GITHUB_WORKSPACE is unset (i.e., logOptions.workspaceRoot is undefined). When workspaceRoot is absent, the new early-return is skipped entirely — meaning the pre-existing fallback still applies. A test confirming "when GITHUB_WORKSPACE is not set, behaviour is unchanged" would prevent future regressions where someone accidentally makes the workspace check mandatory.

💡 Suggested additional assertion
it('falls back to prior behaviour when GITHUB_WORKSPACE is unset', async () => {
  const prevWorkspace = process.env.GITHUB_WORKSPACE;
  delete process.env.GITHUB_WORKSPACE;
  try {
    const handler = await makePermissionHandlerViaSDK(['shell(ls)']);
    // Without a workspace root, workspace-path reads are still denied (no regression in fallback).
    expect(handler({ 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.',
    });
  } finally {
    if (prevWorkspace === undefined) delete process.env.GITHUB_WORKSPACE;
    else process.env.GITHUB_WORKSPACE = prevWorkspace;
  }
});

This pins the contract: the early-return is only active when workspaceRoot is provided.

@copilot please address this.

@gh-aw-bot

Copy link
Copy Markdown
Collaborator

@copilot Please continue triage on this PR.

Remaining visible blockers (newest first):

Run details: https://github.com/github/gh-aw/actions/runs/30766084787

Generated by 👨‍🍳 PR Sous Chef · gpt54 · 5.13 AIC · ⌖ 5.72 AIC · ⊞ 5.9K ·
Comment /souschef to run again

…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>

Copilot AI commented Aug 2, 2026

Copy link
Copy Markdown
Contributor Author

@copilot Please continue triage on this PR.

Remaining visible blockers (newest first):...

Addressed in commit fixing the workspace read containment check.

The string-prefix check has been replaced with path.resolve + path.relative:

case "read":
  if (logOptions?.workspaceRoot && typeof request.path === "string" && request.path.length > 0) {
    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;
    }
  }

This correctly:

  • Rejects traversal pathsworkspace/../../../../etc/passwd resolves outside the workspace, so path.relative returns a ..-prefixed path and the check fails.
  • Approves relative pathsAGENTS.md is resolved against the workspace root via path.resolve(resolvedWorkspace, request.path), then path.relative confirms it's inside.

Tests updated to cover both cases (traversal rejection + relative path approval).

@github-actions

github-actions Bot commented Aug 2, 2026

Copy link
Copy Markdown
Contributor

Thanks for the fix! 🎯

This PR looks great — it directly addresses the permission scoping regression that was killing daily workflows (Daily Testify, Linter Miner). The fix is:

Focused — workspace-root early-return added cleanly, with matching test coverage
Well-tested — regression test reproduces the exact scenario (github MCP + restricted bash, no read grant), plus updates to existing assertions
Well-documented — clear root cause analysis and before/after code samples
Follows process — created via core-team agentic workflow, properly references issue #49836

The change is minimal and targeted: if the requested path is under GITHUB_WORKSPACE, approve the read unconditionally. Paths outside the workspace remain subject to existing rules. This is ready for review!

Generated by ✅ Contribution Check · auto · 46.3 AIC · ⌖ 17.4 AIC · ⊞ 8.8K ·

@pelikhan
pelikhan merged commit 6db61dc into main Aug 2, 2026
8 checks passed
@pelikhan
pelikhan deleted the copilot/aw-failures-fix-read-tool-permission-scoping branch August 2, 2026 21:33
@github-actions

github-actions Bot commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

🎉 This pull request is included in a new release.

Release: v0.84.3

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[aw-failures] Read-tool permission scoping regression crashes read-only workflows on their own checkout root

4 participants