Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 7 additions & 2 deletions actions/setup/js/handle_agent_failure.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -1261,14 +1261,19 @@ function buildToolDenialsExceededContext(events, workflowId) {
renderTemplate(template, {
denial_count: denialCount,
threshold,
reason: `\`${normalizedReason}\``,
reason: normalizedReason,
workflow_id: workflowId || "the workflow",
})
);
} catch {
return (
buildWarningAlertLine("Excessive Tool Denials", `The Copilot SDK stopped the session after ${denialCount}/${threshold} permission denials.`) +
`**Last denied request:** \`${normalizedReason}\`\n\n` +
"<details>\n" +
"<summary><strong>Last denied request</strong></summary>\n\n" +
"```text\n" +
`${normalizedReason}\n` +
"```\n\n" +
"</details>\n\n" +
"This is a guardrail stop (`guard.tool_denials_exceeded`) and indicates the workflow's allowed tool set does not match the prompt's requested actions.\n"
);
}
Expand Down
5 changes: 5 additions & 0 deletions actions/setup/js/handle_agent_failure.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -2482,6 +2482,10 @@ describe("handle_agent_failure", () => {
expect(result).toContain("guard.tool_denials_exceeded");
expect(result).toContain("daily-spdd-spec-planner");
expect(result).toContain("> [!WARNING]");
expect(result).toContain("<details>");
expect(result).toContain("<summary><strong>Last denied request</strong></summary>");
expect(result).toContain("```text");
expect(result).toContain("\nread\n");

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.

The primary behavioral change (fallback removal) has zero test coverage.

These new assertions verify the happy-path template rendering, but there is no test for what happens when getPromptPath or fs.readFileSync fails. That is the key semantic change in this patch: previously the function returned a hardcoded fallback string on any template error; now it throws. A missing test means the regression described in the handle_agent_failure.cjs comment can re-emerge silently.

💡 Suggested fix

Add a test that stubs the file system to throw and asserts the function still produces output rather than propagating the exception — or, if the new intent is to let it throw, document that explicitly with a test asserting the exception type so the caller contract is locked:

it("throws when the template file is missing", () => {
  // If the design intent is fail-fast, this test documents the contract.
  const tmpEnv = process.env.GH_AW_PROMPTS_DIR;
  delete process.env.GH_AW_PROMPTS_DIR;
  delete process.env.RUNNER_TEMP;
  try {
    expect(() =>
      buildToolDenialsExceededContext([{ denialCount: 1, threshold: 5, reason: "permission denied: read" }], "wf")
    ).toThrow();
  } finally {
    if (tmpEnv !== undefined) process.env.GH_AW_PROMPTS_DIR = tmpEnv;
  }
});

Without a test in either direction, this edge case is invisible to future refactors.

});

it("normalizes Python 3 heredoc reason to a single-line summary", () => {
Expand All @@ -2492,6 +2496,7 @@ describe("handle_agent_failure", () => {
const python3Reason = 'permission denied: shell(python3 << \'EOF\'\nimport re\n\nfiles = [("foo.go", "/path/foo.go")]\nfor f, p in files:\n print(f)\nEOF)';
const result = buildToolDenialsExceededContext([{ denialCount: 5, threshold: 5, reason: python3Reason }], "daily-compiler-quality");
expect(result).toContain("shell(python3 ...)");
expect(result).not.toContain("`shell(python3 ...)`");
// The full multi-line program body should not appear in the output
expect(result).not.toContain("import re");
expect(result).not.toContain("for f, p in files");
Expand Down
11 changes: 9 additions & 2 deletions actions/setup/md/tool_denials_exceeded_context.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,14 @@
> [!WARNING]
> **Excessive Tool Denials**: The Copilot SDK hit the max tool denial guardrail and stopped the session early (`{denial_count}/{threshold}`).

**Last denied request:**
<details>
<summary><strong>Last denied request</strong></summary>

```text
{reason}

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.

Raw substitution of {reason} into a fenced code block: triple-backtick content in the denied command can break fence rendering.

renderTemplate does a single-pass regex replacement with no escaping. If normalizedReason contains ``` (possible for the fallthrough case in normalizeDeniedPermissionCommand — any command not matching the read(...) or heredoc patterns is returned verbatim), the fence opened on the line above closes early and the remaining reason text renders as raw markdown outside the block.

💡 Suggested fix

Sanitize the reason before substitution in the caller:

reason: normalizedReason.replace(/`{3,}/g, "``"),

Or add a dedicated escaping step in renderTemplate / document that callers must pre-escape fence characters. The case is unlikely today given normalizeDeniedPermissionCommand's normalisation rules, but the assumption is implicit and fragile if new tool types are added later.

```

</details>

This is a structured guardrail event (`guard.tool_denials_exceeded`) captured in `events.jsonl`.

Expand All @@ -16,7 +22,8 @@ Update the workflow prompt and/or permissions so required actions are permitted:

```
The workflow {workflow_id} stopped because the Copilot SDK exceeded its tool denial threshold ({denial_count}/{threshold}).
Last denied request: {reason}
Last denied request:
{reason}

Please update the workflow so the prompt only uses tools permitted by the workflow tool policy.
```
Expand Down
Loading