From f46dd0bc5c488c6377bae0ff07963b3e63004dba Mon Sep 17 00:00:00 2001
From: "github-actions[bot]"
<41898282+github-actions[bot]@users.noreply.github.com>
Date: Sat, 6 Jun 2026 20:52:13 +0000
Subject: [PATCH] chore: sync actions from gh-aw@v0.78.3
---
setup/js/bash_command_parser.cjs | 262 ++++++++++
.../js/bash_command_parser_spec_vectors.json | 196 ++++++++
setup/js/checkout_manifest.cjs | 99 ++++
setup/js/checkout_pr_branch.cjs | 36 ++
setup/js/collect_ndjson_output.cjs | 6 +-
setup/js/copilot_sdk_driver.cjs | 465 ++----------------
setup/js/copilot_sdk_permissions.cjs | 395 +++++++++++++++
setup/js/copilot_sdk_session.cjs | 341 +++++++++++++
setup/js/create_pull_request.cjs | 23 +-
setup/js/effective_tokens.cjs | 35 +-
setup/js/effective_tokens_context.cjs | 319 ++++++------
setup/js/fuzz_bash_command_parser_harness.cjs | 174 +++++++
setup/js/generate_git_bundle.cjs | 103 ++--
setup/js/generate_git_patch.cjs | 115 ++---
setup/js/git_helpers.cjs | 237 ++++++++-
setup/js/git_patch_utils.cjs | 8 +-
setup/js/handle_agent_failure.cjs | 248 +++++++++-
setup/js/handle_detection_runs.cjs | 7 -
setup/js/handle_noop_message.cjs | 7 -
setup/js/mcp_http_transport.cjs | 5 +-
setup/js/mcp_server_core.cjs | 46 +-
setup/js/messages_footer.cjs | 43 +-
setup/js/model_costs.cjs | 14 +-
setup/js/parse_mcp_gateway_log.cjs | 36 +-
setup/js/parse_token_usage.cjs | 42 +-
setup/js/permission_denied_helpers.cjs | 15 +
setup/js/push_signed_commits.cjs | 111 ++++-
setup/js/push_to_pull_request_branch.cjs | 23 +-
setup/js/resolve_transport_paths.cjs | 51 ++
setup/js/route_slash_command.cjs | 54 +-
setup/js/safe_output_type_validator.cjs | 8 -
setup/js/safe_outputs_handlers.cjs | 79 ++-
setup/js/safe_outputs_mcp_arguments.cjs | 38 ++
setup/js/safe_outputs_mcp_server.cjs | 9 +-
setup/js/safe_outputs_mcp_server_http.cjs | 2 +
setup/js/safe_outputs_tools.json | 2 +-
setup/md/agent_failure_comment.md | 2 +-
setup/md/agent_failure_issue.md | 2 +-
setup/md/ai_credits_rate_limit_error.md | 10 +
setup/md/daily_cap_rollup_comment.md | 9 +
setup/md/daily_cap_rollup_issue.md | 13 +
setup/md/detection_runs_comment.md | 2 +-
setup/md/effective_tokens_rate_limit_error.md | 14 +-
setup/md/noop_comment.md | 2 +-
setup/md/tool_denials_exceeded_context.md | 23 +
setup/setup.sh | 2 +
setup/sh/start_safe_outputs_server.sh | 2 +
47 files changed, 2826 insertions(+), 909 deletions(-)
create mode 100644 setup/js/bash_command_parser.cjs
create mode 100644 setup/js/bash_command_parser_spec_vectors.json
create mode 100644 setup/js/checkout_manifest.cjs
create mode 100644 setup/js/copilot_sdk_permissions.cjs
create mode 100644 setup/js/copilot_sdk_session.cjs
create mode 100644 setup/js/fuzz_bash_command_parser_harness.cjs
create mode 100644 setup/js/resolve_transport_paths.cjs
create mode 100644 setup/js/safe_outputs_mcp_arguments.cjs
create mode 100644 setup/md/ai_credits_rate_limit_error.md
create mode 100644 setup/md/daily_cap_rollup_comment.md
create mode 100644 setup/md/daily_cap_rollup_issue.md
create mode 100644 setup/md/tool_denials_exceeded_context.md
diff --git a/setup/js/bash_command_parser.cjs b/setup/js/bash_command_parser.cjs
new file mode 100644
index 00000000..f83b5548
--- /dev/null
+++ b/setup/js/bash_command_parser.cjs
@@ -0,0 +1,262 @@
+// @ts-check
+"use strict";
+
+/**
+ * bash_command_parser.cjs
+ *
+ * Dedicated bash command parser for permission checking in the Copilot SDK driver.
+ *
+ * Provides utilities to:
+ * - Split a shell command text on pipeline operators (&&, ||, |, ;)
+ * - Extract the executable command name from a shell segment
+ * - Extract all command names from a complex piped/chained command
+ *
+ * This parser enables the permission checker to handle chained shell commands such as
+ * ls /tmp && cat file.json 2>/dev/null || echo "not found"
+ * by extracting individual command names and verifying each one against the allow-list.
+ *
+ * The parser uses a lightweight state machine that respects single-quoted and
+ * double-quoted strings so that operators embedded inside quotes are not treated
+ * as pipeline separators. Subshell expressions $(...) are also skipped as a unit.
+ *
+ * Security invariant: when parsing is ambiguous or no command names can be extracted
+ * the caller receives an empty array and the permission checker falls back to denying
+ * the request, ensuring a safe default.
+ */
+
+/**
+ * Split a shell command text into individual pipeline segments.
+ * Splits on the following shell operators: &&, ||, |, ;
+ *
+ * The split respects:
+ * - Single-quoted strings (no escaping inside)
+ * - Double-quoted strings (backslash-escape aware)
+ * - $(...) subshell expressions (balanced parentheses)
+ *
+ * Operators embedded inside any of these constructs are not treated as separators.
+ *
+ * @param {string} commandText - Raw bash command text that may contain pipeline operators
+ * @returns {string[]} Non-empty trimmed segments (operators removed)
+ */
+function splitOnPipelineOperators(commandText) {
+ if (!commandText || typeof commandText !== "string") return [];
+
+ const segments = [];
+ let current = "";
+ let i = 0;
+ const len = commandText.length;
+
+ while (i < len) {
+ const ch = commandText[i];
+
+ // ── Single-quoted string: no escape sequences, copy verbatim until closing ' ──
+ if (ch === "'") {
+ current += ch;
+ i++;
+ while (i < len && commandText[i] !== "'") {
+ current += commandText[i];
+ i++;
+ }
+ if (i < len) {
+ current += commandText[i]; // closing '
+ i++;
+ }
+ continue;
+ }
+
+ // ── Double-quoted string: backslash escapes are recognised ──
+ if (ch === '"') {
+ current += ch;
+ i++;
+ while (i < len && commandText[i] !== '"') {
+ if (commandText[i] === "\\" && i + 1 < len) {
+ current += commandText[i] + commandText[i + 1];
+ i += 2;
+ } else {
+ current += commandText[i];
+ i++;
+ }
+ }
+ if (i < len) {
+ current += commandText[i]; // closing "
+ i++;
+ }
+ continue;
+ }
+
+ // ── $(...) subshell: skip balanced parentheses as a unit ──
+ if (ch === "$" && i + 1 < len && commandText[i + 1] === "(") {
+ current += ch;
+ i++;
+ let depth = 0;
+ while (i < len) {
+ const sc = commandText[i];
+ if (sc === "(") depth++;
+ else if (sc === ")") {
+ depth--;
+ current += sc;
+ i++;
+ if (depth === 0) break;
+ continue;
+ }
+ current += sc;
+ i++;
+ }
+ continue;
+ }
+
+ // ── Pipeline operators ──
+
+ // && (AND-then)
+ if (ch === "&" && i + 1 < len && commandText[i + 1] === "&") {
+ segments.push(current);
+ current = "";
+ i += 2;
+ while (i < len && commandText[i] !== undefined && /\s/.test(commandText[i])) i++;
+ continue;
+ }
+
+ // || (OR-else) — must be checked before lone |
+ if (ch === "|" && i + 1 < len && commandText[i + 1] === "|") {
+ segments.push(current);
+ current = "";
+ i += 2;
+ while (i < len && /\s/.test(commandText[i])) i++;
+ continue;
+ }
+
+ // | (pipe)
+ if (ch === "|") {
+ segments.push(current);
+ current = "";
+ i++;
+ while (i < len && /\s/.test(commandText[i])) i++;
+ continue;
+ }
+
+ // ; (sequential)
+ if (ch === ";") {
+ segments.push(current);
+ current = "";
+ i++;
+ while (i < len && /\s/.test(commandText[i])) i++;
+ continue;
+ }
+
+ current += ch;
+ i++;
+ }
+
+ // Push the final segment
+ if (current.trim()) {
+ segments.push(current);
+ }
+
+ return segments.map(s => s.trim()).filter(s => s.length > 0);
+}
+
+/**
+ * Shell flow-control keywords that can appear as the first word of a segment
+ * but do not represent an executable command. They must be excluded so the
+ * permission checker does not attempt to look up keywords like "then" or "fi"
+ * as command names and incorrectly deny (or allow) a pipeline that contains
+ * them as part of a compound statement (e.g. `if …; then cat …; fi`).
+ */
+const SHELL_KEYWORDS = new Set(["then", "else", "elif", "fi", "do", "done", "esac", "in", "function", "time", "coproc"]);
+
+/**
+ * Extract the executable command name from a single shell command segment.
+ *
+ * Skips:
+ * - Leading env-var assignments: VAR=value (any number of them)
+ * - Shell negation operator: !
+ * - Shell grouping braces: { }
+ * - Redirection words that begin with < > or a digit followed by < > &
+ * - Shell flow-control keywords (then, else, fi, do, done, …)
+ *
+ * Returns null when no executable command name can be determined.
+ *
+ * @param {string} segment - A single shell segment containing no pipeline operators
+ * @returns {string | null} The command name, or null if not extractable
+ */
+function extractCommandName(segment) {
+ if (!segment || typeof segment !== "string") return null;
+
+ let remaining = segment.trim();
+ if (!remaining) return null;
+
+ // Skip leading env-var assignments: IDENTIFIER=anything (repeat)
+ const envAssignRe = /^[A-Za-z_][A-Za-z0-9_]*=\S*\s*/;
+ for (;;) {
+ const m = remaining.match(envAssignRe);
+ if (!m) break;
+ remaining = remaining.slice(m[0].length).trim();
+ }
+
+ if (!remaining) return null;
+
+ // Get the first word
+ const wordMatch = remaining.match(/^(\S+)/);
+ if (!wordMatch) return null;
+
+ const word = wordMatch[1];
+
+ // Redirection operators (<, >, 2>, 2>&1, …)
+ if (/^[<>]/.test(word) || /^\d+[<>&]/.test(word)) {
+ return null;
+ }
+
+ // Shell negation / grouping — recurse on the remainder
+ if (word === "!" || word === "{" || word === "}") {
+ const rest = remaining.slice(word.length).trim();
+ return extractCommandName(rest);
+ }
+
+ // Flow-control keywords are not executable commands
+ if (SHELL_KEYWORDS.has(word)) {
+ return null;
+ }
+
+ return word;
+}
+
+/**
+ * Extract all unique command names from a bash pipeline or command sequence.
+ *
+ * Splits the text on &&, ||, |, and ; and extracts the executable command name
+ * from each resulting segment. Returns a deduplicated array preserving
+ * first-occurrence order.
+ *
+ * Returns an empty array when the text is empty, unparseable, or yields no
+ * recognisable command names. Callers should treat an empty result as
+ * "unable to determine commands" and fall back to a safe default (deny).
+ *
+ * @param {string} commandText - Raw bash command text (may include pipeline operators)
+ * @returns {string[]} Deduplicated array of command names in first-occurrence order
+ */
+function extractCommandNamesFromPipeline(commandText) {
+ if (!commandText || typeof commandText !== "string") return [];
+
+ const text = commandText.trim();
+ if (!text) return [];
+
+ const segments = splitOnPipelineOperators(text);
+ const seen = new Set();
+ const names = [];
+
+ for (const segment of segments) {
+ const name = extractCommandName(segment);
+ if (name && !seen.has(name)) {
+ seen.add(name);
+ names.push(name);
+ }
+ }
+
+ return names;
+}
+
+module.exports = {
+ splitOnPipelineOperators,
+ extractCommandName,
+ extractCommandNamesFromPipeline,
+};
diff --git a/setup/js/bash_command_parser_spec_vectors.json b/setup/js/bash_command_parser_spec_vectors.json
new file mode 100644
index 00000000..897a52c6
--- /dev/null
+++ b/setup/js/bash_command_parser_spec_vectors.json
@@ -0,0 +1,196 @@
+{
+ "version": "1.0.0",
+ "metadata": {
+ "spec": "docs/src/content/docs/specs/bash-command-parser-specification.md",
+ "description": "Language-agnostic conformance vectors for the bash command parser",
+ "sources": ["model-based", "verification"]
+ },
+ "vectors": {
+ "splitOnPipelineOperators": [
+ {
+ "id": "BP-SP-101",
+ "source": "model-based",
+ "input": "ls&&cat",
+ "expected": ["ls", "cat"]
+ },
+ {
+ "id": "BP-SP-102",
+ "source": "model-based",
+ "input": "echo \"a && b\" && echo c",
+ "expected": ["echo \"a && b\"", "echo c"]
+ },
+ {
+ "id": "BP-SP-103",
+ "source": "model-based",
+ "input": "echo 'x|y' | cat",
+ "expected": ["echo 'x|y'", "cat"]
+ },
+ {
+ "id": "BP-SP-104",
+ "source": "model-based",
+ "input": "echo $(printf \"x;y\") ; date",
+ "expected": ["echo $(printf \"x;y\")", "date"]
+ },
+ {
+ "id": "BP-SP-105",
+ "source": "model-based",
+ "input": "echo $(echo $(ls && pwd)) && date",
+ "expected": ["echo $(echo $(ls && pwd))", "date"]
+ },
+ {
+ "id": "BP-SP-106",
+ "source": "model-based",
+ "input": "cat file.json||echo missing",
+ "expected": ["cat file.json", "echo missing"]
+ },
+ {
+ "id": "BP-SP-151",
+ "source": "verification",
+ "input": " echo a ;;; echo b ",
+ "expected": ["echo a", "echo b"]
+ },
+ {
+ "id": "BP-SP-152",
+ "source": "verification",
+ "input": " ! ls /tmp && echo done ",
+ "expected": ["! ls /tmp", "echo done"]
+ }
+ ],
+ "extractCommandName": [
+ {
+ "id": "BP-EC-101",
+ "source": "model-based",
+ "input": "FOO=1 BAR=2 env | grep FOO",
+ "expected": "env"
+ },
+ {
+ "id": "BP-EC-102",
+ "source": "model-based",
+ "input": "! printf '%s' ok",
+ "expected": "printf"
+ },
+ {
+ "id": "BP-EC-103",
+ "source": "model-based",
+ "input": "{ jq '.a' data.json; }",
+ "expected": "jq"
+ },
+ {
+ "id": "BP-EC-104",
+ "source": "model-based",
+ "input": "2>&1",
+ "expected": null
+ },
+ {
+ "id": "BP-EC-105",
+ "source": "model-based",
+ "input": "then cat file",
+ "expected": null
+ },
+ {
+ "id": "BP-EC-151",
+ "source": "verification",
+ "input": " VERBOSE=1 make build ",
+ "expected": "make"
+ },
+ {
+ "id": "BP-EC-152",
+ "source": "verification",
+ "input": ">out.txt",
+ "expected": null
+ },
+ {
+ "id": "BP-EC-153",
+ "source": "verification",
+ "input": "coproc",
+ "expected": null
+ }
+ ],
+ "extractCommandNamesFromPipeline": [
+ {
+ "id": "BP-EP-101",
+ "source": "model-based",
+ "input": "echo \"a && b\" && echo c",
+ "expected": ["echo"]
+ },
+ {
+ "id": "BP-EP-102",
+ "source": "model-based",
+ "input": "FOO=1 BAR=2 env | grep FOO",
+ "expected": ["env", "grep"]
+ },
+ {
+ "id": "BP-EP-103",
+ "source": "model-based",
+ "input": "{ ls /tmp; } && echo done",
+ "expected": ["ls", "echo"]
+ },
+ {
+ "id": "BP-EP-104",
+ "source": "model-based",
+ "input": "safeoutputs --help || safeoutputs missing_data",
+ "expected": ["safeoutputs"]
+ },
+ {
+ "id": "BP-EP-151",
+ "source": "verification",
+ "input": "pwd; ls; pwd; ls; echo done",
+ "expected": ["pwd", "ls", "echo"]
+ },
+ {
+ "id": "BP-EP-152",
+ "source": "verification",
+ "input": "do && ls /tmp",
+ "expected": ["ls"]
+ },
+ {
+ "id": "BP-EP-153",
+ "source": "verification",
+ "input": "cat $(ls /tmp)",
+ "expected": ["cat"]
+ }
+ ]
+ },
+ "metamorphic": [
+ {
+ "id": "BP-MR-001",
+ "function": "splitOnPipelineOperators",
+ "relation": "whitespace-invariance",
+ "left": "ls /tmp&&cat file",
+ "right": " ls /tmp && cat file ",
+ "expected": ["ls /tmp", "cat file"]
+ },
+ {
+ "id": "BP-MR-002",
+ "function": "extractCommandName",
+ "relation": "env-prefix-invariance",
+ "left": "ls /tmp",
+ "right": "FOO=1 BAR=2 ls /tmp",
+ "expected": "ls"
+ },
+ {
+ "id": "BP-MR-003",
+ "function": "extractCommandName",
+ "relation": "redirection-suffix-invariance",
+ "left": "jq '.x' results.json",
+ "right": "jq '.x' results.json 2>/dev/null",
+ "expected": "jq"
+ },
+ {
+ "id": "BP-MR-004",
+ "function": "extractCommandNamesFromPipeline",
+ "relation": "duplicate-collapse",
+ "left": "echo a && cat b && echo c",
+ "right": "echo a && cat b",
+ "expected": ["echo", "cat"]
+ },
+ {
+ "id": "BP-MR-005",
+ "function": "extractCommandNamesFromPipeline",
+ "relation": "quoted-operator-shielding",
+ "left": "echo \"a && b\"",
+ "right": "echo 'a && b'",
+ "expected": ["echo"]
+ }
+ ]
+}
diff --git a/setup/js/checkout_manifest.cjs b/setup/js/checkout_manifest.cjs
new file mode 100644
index 00000000..dd3c8ea9
--- /dev/null
+++ b/setup/js/checkout_manifest.cjs
@@ -0,0 +1,99 @@
+// @ts-check
+///
+
+const fs = require("fs");
+const path = require("path");
+
+const { getErrorMessage } = require("./error_helpers.cjs");
+
+/**
+ * Loader for the checkout manifest written by the compiler-emitted
+ * "Build checkout manifest for safe-outputs handlers" step.
+ *
+ * Layout on disk (JSON object keyed by lowercase repo slug):
+ * {
+ * "owner/repo": { "repository": "owner/repo", "path": "github", "default_branch": "master" }
+ * }
+ *
+ * The MCP server runs in a credential-less container, so the manifest is the
+ * authoritative source for resolving the on-disk checkout path and base branch
+ * of cross-repo checkouts without any network access.
+ *
+ * The default location is $RUNNER_TEMP/gh-aw/checkout-manifest.json. Override
+ * with GH_AW_CHECKOUT_MANIFEST when running outside of a GitHub Actions runner
+ * (tests, local dev).
+ */
+
+let cached = null;
+
+function resolveManifestPath() {
+ const explicit = process.env.GH_AW_CHECKOUT_MANIFEST;
+ if (explicit && explicit.trim() !== "") {
+ return explicit.trim();
+ }
+ const runnerTemp = process.env.RUNNER_TEMP;
+ if (!runnerTemp || runnerTemp.trim() === "") {
+ return null;
+ }
+ return path.join(runnerTemp, "gh-aw", "checkout-manifest.json");
+}
+
+function loadManifest() {
+ if (cached !== null) {
+ return cached;
+ }
+ const manifestPath = resolveManifestPath();
+ if (!manifestPath) {
+ cached = {};
+ return cached;
+ }
+ try {
+ const raw = fs.readFileSync(manifestPath, "utf8");
+ const parsed = JSON.parse(raw);
+ cached = parsed && typeof parsed === "object" ? parsed : {};
+ } catch (err) {
+ if (err && err.code !== "ENOENT" && typeof core !== "undefined") {
+ core.debug(`checkout_manifest: failed to read ${manifestPath}: ${getErrorMessage(err)}`);
+ }
+ cached = {};
+ }
+ return cached;
+}
+
+/**
+ * Look up a checkout manifest entry by repo slug ("owner/repo", case-insensitive).
+ * Returns null when no entry exists.
+ *
+ * @param {string | undefined | null} repoSlug
+ * @returns {{ repository: string, path: string, default_branch: string } | null}
+ */
+function lookupCheckout(repoSlug) {
+ if (!repoSlug || typeof repoSlug !== "string") {
+ return null;
+ }
+ const key = repoSlug.trim().toLowerCase();
+ if (!key) {
+ return null;
+ }
+ const manifest = loadManifest();
+ const entry = manifest[key];
+ if (!entry || typeof entry !== "object") {
+ return null;
+ }
+ const repository = typeof entry.repository === "string" ? entry.repository : repoSlug;
+ const entryPath = typeof entry.path === "string" ? entry.path : "";
+ const defaultBranch = typeof entry.default_branch === "string" ? entry.default_branch : "";
+ return { repository, path: entryPath, default_branch: defaultBranch };
+}
+
+/**
+ * Reset the cached manifest. Intended for tests.
+ */
+function _resetCache() {
+ cached = null;
+}
+
+module.exports = {
+ lookupCheckout,
+ _resetCache,
+};
diff --git a/setup/js/checkout_pr_branch.cjs b/setup/js/checkout_pr_branch.cjs
index 0add4196..4043ff7a 100644
--- a/setup/js/checkout_pr_branch.cjs
+++ b/setup/js/checkout_pr_branch.cjs
@@ -29,6 +29,7 @@ const { getErrorMessage } = require("./error_helpers.cjs");
const { renderTemplateFromFile, getPromptPath } = require("./messages_core.cjs");
const { detectForkPR } = require("./pr_helpers.cjs");
const { ERR_API } = require("./error_codes.cjs");
+const TRUSTED_CHECKOUT_PERMISSIONS = ["write", "maintain", "admin"];
/**
* Log detailed PR context information for debugging
@@ -110,6 +111,39 @@ function logCheckoutStrategy(eventName, strategy, reason) {
core.endGroup();
}
+/**
+ * Ensure checkout step only runs in trusted runtime contexts.
+ * - repository must not be a fork
+ * - triggering actor must have write-or-higher repository permission
+ */
+async function assertTrustedCheckoutRuntime() {
+ const repository = context.payload.repository;
+ if (repository?.fork === true) {
+ throw new Error("Refusing PR checkout in forked repository runtime context");
+ }
+
+ // context.actor is preferred when available; sender.login and GITHUB_ACTOR
+ // are retained as event/runtime-compatible fallbacks.
+ const actor = context.actor || context.payload.sender?.login || process.env.GITHUB_ACTOR;
+ if (!actor) {
+ throw new Error("Refusing PR checkout: unable to determine triggering actor");
+ }
+
+ const { data: permissionData } = await github.rest.repos.getCollaboratorPermissionLevel({
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ username: actor,
+ });
+
+ const permission = permissionData?.permission || "none";
+ const hasWriteOrHigher = TRUSTED_CHECKOUT_PERMISSIONS.includes(permission);
+ if (!hasWriteOrHigher) {
+ throw new Error(`Refusing PR checkout: actor '${actor}' has '${permission}' permission (requires write or higher)`);
+ }
+
+ core.info(`Runtime safety check passed for actor '${actor}' with '${permission}' permission`);
+}
+
async function main() {
const eventName = context.eventName;
// For pull_request events, the PR context is in context.payload.pull_request.
@@ -142,6 +176,8 @@ async function main() {
}
try {
+ await assertTrustedCheckoutRuntime();
+
// Log detailed context for debugging
const { isFork } = logPRContext(eventName, pullRequest);
diff --git a/setup/js/collect_ndjson_output.cjs b/setup/js/collect_ndjson_output.cjs
index d7bab7f7..a25887a6 100644
--- a/setup/js/collect_ndjson_output.cjs
+++ b/setup/js/collect_ndjson_output.cjs
@@ -323,10 +323,8 @@ async function main() {
}
continue;
}
- // SECURITY: Use normalizedItem (which strips infrastructure-only fields
- // like patch_path, bundle_path, base_commit, diff_size) instead of the
- // original item, to prevent agent-injected transport metadata from
- // reaching the privileged handler.
+ // Use the normalized item (with sanitized/validated fields) rather
+ // than the raw input, so downstream consumers see the canonical form.
core.info(`Line ${i + 1}: Valid ${itemType} item`);
parsedItems.push(validationResult.normalizedItem);
} else {
diff --git a/setup/js/copilot_sdk_driver.cjs b/setup/js/copilot_sdk_driver.cjs
index 0380bffe..b35ab970 100644
--- a/setup/js/copilot_sdk_driver.cjs
+++ b/setup/js/copilot_sdk_driver.cjs
@@ -1,443 +1,36 @@
// @ts-check
/**
- * Copilot SDK Driver
+ * Copilot SDK Driver (entry point)
*
- * Uses @github/copilot-sdk to drive a Copilot session against a running headless
- * Copilot CLI server (started by copilot_sdk_sidecar.cjs). Serializes all SDK
- * session events to a JSONL file so that unified_timeline.cjs can render them.
+ * Minimal standalone program launched by copilot_harness.cjs. Reads
+ * configuration from environment variables, delegates the full session
+ * lifecycle to copilot_sdk_session.cjs, and exits with the session's exit code.
*
- * Event mapping:
- * SDK "user.message" → JSONL "user.message"
- * SDK "tool.execution_start" → JSONL "tool.execution_start" (toolName, mcpServerName)
- * SDK "tool.execution_complete" → JSONL "tool.execution_complete" (toolName, mcpServerName, success)
- * SDK "assistant.message" → JSONL "assistant.message" (content)
- *
- * The JSONL file is written to:
- * /tmp/gh-aw/sandbox/agent/logs/copilot-session-state/{sessionId}/events.jsonl
- * which mirrors the path that copy_copilot_session_state.sh produces and that
- * unified_timeline.cjs reads.
- *
- * When run as a standalone program (require.main === module), the driver reads
- * configuration from environment variables and connects to the sidecar server
- * that has already been started by copilot_harness.cjs:
- *
- * GH_AW_PROMPT — path to the prompt file
- * COPILOT_SDK_URI — SDK server URI (set by the harness)
- * COPILOT_CONNECTION_TOKEN — shared secret for the SDK session (set by the harness)
- * COPILOT_MODEL — model override (optional)
+ * GH_AW_PROMPT — path to the prompt file
+ * COPILOT_SDK_URI — SDK server URI (set by the harness)
+ * COPILOT_CONNECTION_TOKEN — shared secret for the SDK session (set by the harness)
+ * COPILOT_MODEL — model override (optional)
+ * GH_AW_COPILOT_SDK_PROVIDER_BASE_URL — BYOK provider base URL (set by the harness)
+ * GH_AW_COPILOT_SDK_SERVER_ARGS — JSON-encoded allow-tool sidecar args (set by the engine)
*
* The sidecar is started and stopped by the harness; the driver only opens a
- * client connection, runs the session, and exits. This makes the driver a
- * simple client extension that can be started by the harness like any other
- * command, while serving as a sample showing how to create a Copilot SDK driver
- * extension in agentic-workflows.
+ * client connection, runs the session, and exits.
+ *
+ * Reusable helpers live in:
+ * copilot_sdk_permissions.cjs — permission config parsing and handler builder
+ * copilot_sdk_session.cjs — session runner and JSONL event serialization
*/
"use strict";
const fs = require("fs");
-const path = require("path");
-const os = require("os");
-
-// Default timeout for a single sendAndWait call: 10 minutes.
-// This is intentionally generous — the headless Copilot CLI has its own internal
-// timeouts for individual tool calls and model inference.
-// Override via the COPILOT_SDK_SEND_TIMEOUT_MS environment variable.
-const SDK_SEND_TIMEOUT_MS_DEFAULT = 10 * 60 * 1000;
-
-/**
- * @typedef {{
- * allowAllTools?: boolean,
- * allowedTools?: string[],
- * }} CopilotSDKPermissionConfig
- */
-
-/**
- * @typedef {{
- * info?: (message: string) => void,
- * warning?: (message: string) => void,
- * }} CopilotSDKCoreLogger
- */
-
-/**
- * Create a compact, human-readable permission-request summary for diagnostics.
- * Examples: shell(git status), mcp(github.get_file_contents), url(https://example.com).
- *
- * @param {import("@github/copilot-sdk").PermissionRequest} request
- * @returns {string}
- */
-function summarizePermissionRequest(request) {
- switch (request.kind) {
- case "shell":
- return `shell(${String(request.fullCommandText || "").trim() || "unknown"})`;
- case "mcp":
- return `mcp(${request.serverName || "unknown"}.${request.toolName || "unknown"})`;
- case "url":
- return `url(${request.url || "unknown"})`;
- case "write":
- return `write(${request.fileName || "unknown"})`;
- case "read":
- return "read";
- case "custom-tool":
- return `custom-tool(${request.toolName || "unknown"})`;
- default:
- return request.kind;
- }
-}
-
-/**
- * @param {CopilotSDKCoreLogger | undefined} coreLogger
- * @param {(msg: string) => void} logger
- * @param {import("@github/copilot-sdk").PermissionRequest} request
- */
-function logPermissionDenied(coreLogger, logger, request) {
- const requestSummary = summarizePermissionRequest(request);
- logger(`permission denied by workflow tool permissions: ${requestSummary}`);
- if (coreLogger?.info) {
- coreLogger.info(`Copilot SDK permission denied: ${requestSummary}`);
- }
- if (coreLogger?.warning) {
- coreLogger.warning(`Copilot SDK permission denied by workflow tool permissions: ${requestSummary}`);
- }
-}
-
-/**
- * Build a scoped SDK permission handler from Copilot CLI allow-tool rules.
- * When no explicit permission rules exist, return undefined so the SDK applies
- * its built-in policy instead of an AWF override. This mirrors CLI mode where
- * no --allow-tool/--allow-all-tools flags are emitted when no toolsets are configured.
- *
- * @param {CopilotSDKPermissionConfig | undefined} permissionConfig
- * @param {import("@github/copilot-sdk").PermissionHandler} approveAll
- * @param {{coreLogger?: CopilotSDKCoreLogger, logger?: (msg: string) => void}=} logOptions
- * @returns {import("@github/copilot-sdk").PermissionHandler | undefined}
- */
-function buildCopilotSDKPermissionHandler(permissionConfig, approveAll, logOptions) {
- if (!permissionConfig) {
- return undefined;
- }
- const logger = logOptions?.logger ?? (() => {});
-
- const allowAll = permissionConfig?.allowAllTools === true;
- const allowedTools = Array.isArray(permissionConfig?.allowedTools) ? permissionConfig.allowedTools : [];
- const normalizedAllowedTools = allowedTools
- .filter(tool => typeof tool === "string")
- .map(tool => tool.trim())
- .filter(tool => tool.length > 0);
- const allowedToolEntries = new Set(normalizedAllowedTools);
-
- // Keep explicit allow-all behavior when requested by the engine config.
- if (allowAll) {
- return approveAll;
- }
-
- // No explicit rules: use SDK defaults to mirror CLI behavior when no toolsets are set.
- if (allowedToolEntries.size === 0) {
- return undefined;
- }
-
- const shellRules = [...allowedToolEntries]
- .filter(tool => tool.startsWith("shell(") && tool.endsWith(")"))
- .map(tool => tool.slice("shell(".length, -1).trim())
- .filter(Boolean);
-
- /**
- * @param {import("@github/copilot-sdk").PermissionRequest} request
- * @returns {boolean}
- */
- function isAllowed(request) {
- switch (request.kind) {
- case "shell": {
- if (allowedToolEntries.has("shell")) return true;
- const commandIdentifiers = Array.isArray(request.commands) ? request.commands.map(cmd => cmd?.identifier).filter(Boolean) : [];
- const fullCommand = String(request.fullCommandText || "").trim();
- return shellRules.some(rule => {
- if (rule.endsWith(":*")) {
- const prefix = rule.slice(0, -2).trim();
- return prefix.length > 0 && commandIdentifiers.includes(prefix);
- }
- if (!rule.includes(" ")) {
- return commandIdentifiers.includes(rule);
- }
- return fullCommand === rule;
- });
- }
- case "write":
- return allowedToolEntries.has("write");
- case "read":
- return allowedToolEntries.has("read");
- case "url":
- return allowedToolEntries.has("web_fetch");
- case "mcp":
- // Server-only entries (for example: "github") allow all tools from that server.
- // Server+tool entries (for example: "github(get_file_contents)") allow only that tool.
- return allowedToolEntries.has(request.serverName) || allowedToolEntries.has(`${request.serverName}(${request.toolName})`);
- case "custom-tool":
- return allowedToolEntries.has(request.toolName);
- default:
- return false;
- }
- }
-
- return request => {
- if (isAllowed(request)) {
- return { kind: "approve-once" };
- }
- logPermissionDenied(logOptions?.coreLogger, logger, request);
- return { kind: "reject", feedback: "Tool invocation is not allowed by workflow tool permissions." };
- };
-}
-
-/**
- * Extract the prompt text from a resolved args array.
- * Looks for the first occurrence of "-p " or "--prompt ".
- *
- * @param {string[]} args - Resolved args (after resolvePromptFileArgs has run).
- * @returns {string | null} The prompt text, or null if not found.
- */
-function extractPromptFromArgs(args) {
- for (let i = 0; i < args.length - 1; i++) {
- if (args[i] === "-p" || args[i] === "--prompt") {
- return args[i + 1];
- }
- }
- return null;
-}
-
-/**
- * Run a Copilot agentic session using the @github/copilot-sdk.
- *
- * Connects to the already-running headless Copilot CLI server at sdkUri, creates
- * a session, sends the prompt, waits for the session to go idle, and returns a
- * result shape that mirrors what runProcess() returns so that callers can treat
- * both modes uniformly.
- *
- * All SDK events are serialised to a JSONL file under the session state directory
- * so that unified_timeline.cjs can render them in the step summary.
- *
- * @param {{
- * sdkUri: string,
- * prompt: string,
- * logger: (msg: string) => void,
- * attempt?: number,
- * model?: string,
- * connectionToken?: string,
- * provider?: import("@github/copilot-sdk").ProviderConfig,
- * permissionConfig?: {
- * allowAllTools?: boolean,
- * allowedTools?: string[],
- * },
- * coreLogger?: CopilotSDKCoreLogger,
- * sdkModule?: {
- * CopilotClient: typeof import("@github/copilot-sdk").CopilotClient,
- * RuntimeConnection: typeof import("@github/copilot-sdk").RuntimeConnection,
- * approveAll: typeof import("@github/copilot-sdk").approveAll
- * },
- * }} options
- * @returns {Promise<{exitCode: number, output: string, hasOutput: boolean, durationMs: number}>}
- */
-async function runWithCopilotSDK({ sdkUri, prompt, logger, attempt = 0, model, connectionToken, provider, permissionConfig, coreLogger, sdkModule }) {
- // Lazy-require to avoid loading the SDK when it is not needed.
- // The SDK is large and has side-effects on import (worker threads, etc.).
- const { CopilotClient, RuntimeConnection, approveAll } = sdkModule ?? require("@github/copilot-sdk");
-
- const startTime = Date.now();
- let output = "";
- let hasOutput = false;
+const { runWithCopilotSDK, extractPromptFromArgs } = require("./copilot_sdk_session.cjs");
+const { parsePermissionConfigFromServerArgs } = require("./copilot_sdk_permissions.cjs");
- const log = msg => logger(`[sdk-driver] ${msg}`);
- log(`attempt ${attempt + 1}: connecting to Copilot SDK at ${sdkUri}`);
-
- // Session state directory — mirrors the target path used by unified_timeline.cjs.
- // /tmp/gh-aw/sandbox/agent/logs/copilot-session-state/{sessionId}/events.jsonl
- const sessionStateBase = path.join(os.tmpdir(), "gh-aw", "sandbox", "agent", "logs", "copilot-session-state");
-
- /** @type {ReadonlyArray>} */
- const VALID_LOG_LEVELS = ["none", "error", "warning", "info", "debug", "all"];
- const rawLogLevel = process.env.COPILOT_SDK_LOG_LEVEL ?? "";
- /**
- * @param {string} value
- * @returns {value is NonNullable}
- */
- const isValidLogLevel = value => {
- /** @type {readonly string[]} */
- const validLogLevels = VALID_LOG_LEVELS;
- return validLogLevels.includes(value);
- };
- /** @type {import("@github/copilot-sdk").CopilotClientOptions["logLevel"]} */
- const logLevel = isValidLogLevel(rawLogLevel) ? rawLogLevel : "warning";
-
- const connection = RuntimeConnection.forUri(sdkUri, {
- connectionToken,
- });
- const client = new CopilotClient({
- connection,
- workingDirectory: process.env.GITHUB_WORKSPACE || process.cwd(),
- logLevel,
- });
- let session = null;
- /** @type {fs.WriteStream | null} */
- let eventsStream = null;
- let clientStarted = false;
-
- try {
- await client.start();
- clientStarted = true;
- log("client started");
-
- /**
- * Build a scoped permission handler from allow-tool entries.
- * Leaves permissions to SDK defaults when no explicit rules were generated.
- * @type {import("@github/copilot-sdk").PermissionHandler | undefined}
- */
- const onPermissionRequest = buildCopilotSDKPermissionHandler(permissionConfig, approveAll, {
- coreLogger,
- logger: log,
- });
-
- /** @type {import("@github/copilot-sdk").SessionConfig} */
- const sessionConfig = {
- model: model || process.env.COPILOT_MODEL || undefined,
- provider,
- };
- if (onPermissionRequest) {
- sessionConfig.onPermissionRequest = onPermissionRequest;
- }
- session = await client.createSession(sessionConfig);
- log(`session created: sessionId=${session.sessionId}`);
-
- // Prepare JSONL output file for this session.
- const sessionDir = path.join(sessionStateBase, session.sessionId);
- fs.mkdirSync(sessionDir, { recursive: true });
- const eventsPath = path.join(sessionDir, "events.jsonl");
- eventsStream = fs.createWriteStream(eventsPath, { flags: "a" });
- // Snapshot to a non-null local for closure-safe writes (JSDoc nullability narrowing).
- const stream = eventsStream;
- log(`serialising SDK events to ${eventsPath}`);
-
- /**
- * Map from toolCallId → {toolName, mcpServerName} so that tool.execution_complete
- * events (which carry no mcpServerName) can be enriched from the matching start event.
- * @type {Map}
- */
- const pendingToolCalls = new Map();
-
- /**
- * Write one JSONL entry to the events file and stderr.
- * Uses the event's own ISO-8601 timestamp when available.
- *
- * @param {string} type
- * @param {object} data
- * @param {string | undefined} [timestamp]
- */
- function writeEvent(type, data, timestamp) {
- const entry = { type, timestamp: timestamp ?? new Date().toISOString(), data };
- const jsonl = JSON.stringify(entry) + "\n";
- stream.write(jsonl);
- process.stderr.write(jsonl);
- }
-
- // Subscribe to all session events and serialise the ones we care about.
- session.on(event => {
- // Skip transient events that are not persisted by the server.
- if (event.ephemeral) return;
-
- switch (event.type) {
- case "user.message":
- writeEvent("user.message", {}, event.timestamp);
- break;
-
- case "tool.execution_start": {
- const toolName = event.data?.toolName ?? "unknown";
- const mcpServerName = event.data?.mcpServerName ?? "";
- const toolCallId = event.data?.toolCallId;
- if (toolCallId) {
- pendingToolCalls.set(toolCallId, { toolName, mcpServerName });
- }
- writeEvent("tool.execution_start", { toolName, mcpServerName }, event.timestamp);
- break;
- }
-
- case "tool.execution_complete": {
- const toolCallId = event.data?.toolCallId;
- // Resolve toolName/mcpServerName from the matching start event when available.
- const pending = toolCallId ? pendingToolCalls.get(toolCallId) : undefined;
- const toolName = pending?.toolName ?? event.data?.toolDescription?.name ?? "unknown";
- const mcpServerName = pending?.mcpServerName ?? "";
- if (toolCallId) pendingToolCalls.delete(toolCallId);
- const success = event.data?.success ?? !event.data?.error;
- writeEvent("tool.execution_complete", { toolName, mcpServerName, success }, event.timestamp);
- break;
- }
-
- case "assistant.message": {
- const content = event.data?.content ?? "";
- if (content) {
- hasOutput = true;
- output += content;
- }
- writeEvent("assistant.message", { content }, event.timestamp);
- break;
- }
-
- default:
- // Other event types are not consumed by unified_timeline.cjs; skip them.
- break;
- }
- });
-
- log("sending prompt...");
- const sendTimeoutMs = Number(process.env.COPILOT_SDK_SEND_TIMEOUT_MS) || SDK_SEND_TIMEOUT_MS_DEFAULT;
- const result = await session.sendAndWait({ prompt }, sendTimeoutMs);
-
- // sendAndWait returns the last assistant.message event; capture its content
- // as a fallback in case the on() handler missed it.
- if (result && !hasOutput) {
- const content = result.data?.content ?? "";
- if (content) {
- output = content;
- hasOutput = true;
- }
- }
-
- const durationMs = Date.now() - startTime;
- log(`session completed: hasOutput=${hasOutput} durationMs=${durationMs}`);
-
- return { exitCode: 0, output, hasOutput, durationMs };
- } catch (err) {
- const durationMs = Date.now() - startTime;
- log(`error: ${err instanceof Error ? err.message : String(err)}`);
- return {
- exitCode: 1,
- output: err instanceof Error ? err.message : String(err),
- hasOutput: false,
- durationMs,
- };
- } finally {
- // Snapshot for null-safe cleanup in this scope.
- const stream = eventsStream;
- if (stream) {
- await new Promise(resolve => stream.end(resolve));
- }
- if (session) {
- try {
- await session.disconnect();
- } catch {
- // best-effort cleanup
- }
- }
- if (clientStarted) {
- try {
- await client.stop();
- } catch {
- // best-effort cleanup
- }
- }
- }
-}
-
-module.exports = { extractPromptFromArgs, runWithCopilotSDK };
+// Re-export the session and permission helpers so that existing callers that
+// require("./copilot_sdk_driver.cjs") (e.g. copilot_harness.cjs) continue to work.
+module.exports = { extractPromptFromArgs, runWithCopilotSDK, parsePermissionConfigFromServerArgs };
// ---------------------------------------------------------------------------
// Standalone entry point
@@ -506,6 +99,23 @@ async function main() {
/** @type {import("@github/copilot-sdk").ProviderConfig} */
const provider = { type: "openai", baseUrl: providerBaseUrl };
+ // --- Build permission config from sidecar server args ----------------
+ // GH_AW_COPILOT_SDK_SERVER_ARGS holds the JSON-encoded --allow-tool flags
+ // that the Go engine passed to the sidecar. Mirror those same rules in the
+ // SDK session so the driver's onPermissionRequest handler aligns with the
+ // sidecar's pre-configured allow list (e.g. shell(safeoutputs:*) for
+ // workflows with safe-outputs enabled and a restricted bash allowlist).
+ const permissionConfig = parsePermissionConfigFromServerArgs(process.env.GH_AW_COPILOT_SDK_SERVER_ARGS);
+ if (permissionConfig) {
+ if (permissionConfig.allowAllTools) {
+ log("permission config: allow-all-tools (sidecar launched with --allow-all-tools)");
+ } else {
+ log(`permission config: ${(permissionConfig.allowedTools ?? []).length} allow-tool entries from GH_AW_COPILOT_SDK_SERVER_ARGS`);
+ }
+ } else {
+ log("permission config: none (onPermissionRequest will use unrestricted behavior)");
+ }
+
// --- Run SDK session -------------------------------------------------
const result = await runWithCopilotSDK({
@@ -515,6 +125,7 @@ async function main() {
model,
connectionToken,
provider,
+ permissionConfig,
});
process.exit(result.exitCode);
diff --git a/setup/js/copilot_sdk_permissions.cjs b/setup/js/copilot_sdk_permissions.cjs
new file mode 100644
index 00000000..8fcfbeef
--- /dev/null
+++ b/setup/js/copilot_sdk_permissions.cjs
@@ -0,0 +1,395 @@
+// @ts-check
+
+/**
+ * Copilot SDK Permission Helpers
+ *
+ * Provides reusable permission-enforcement utilities for Copilot SDK driver
+ * implementations. Extracts allow-tool rules from sidecar server args, builds
+ * an on-permission handler that enforces those rules, and logs every denial.
+ *
+ * Consumed by copilot_sdk_session.cjs (the built-in driver) and available to
+ * any custom driver that wants to mirror the same permission policy.
+ */
+
+"use strict";
+
+const path = require("path");
+const { extractCommandNamesFromPipeline } = require("./bash_command_parser.cjs");
+
+/** @const {number} Default maximum number of permission denials before the session is stopped. */
+const MAX_TOOL_DENIALS_DEFAULT = 5;
+
+/**
+ * @typedef {{
+ * allowAllTools?: boolean,
+ * allowedTools?: string[],
+ * }} CopilotSDKPermissionConfig
+ */
+
+/**
+ * @typedef {{
+ * info?: (message: string) => void,
+ * warning?: (message: string) => void,
+ * }} CopilotSDKCoreLogger
+ */
+
+/**
+ * Parse a strict positive integer from a number or string.
+ * Returns undefined when the input is not a whole positive integer.
+ *
+ * @param {unknown} value
+ * @returns {number | undefined}
+ */
+function parseStrictPositiveInteger(value) {
+ if (typeof value === "number" && Number.isSafeInteger(value) && value > 0) {
+ return value;
+ }
+ if (typeof value === "string") {
+ const trimmed = value.trim();
+ if (/^\d+$/.test(trimmed)) {
+ const parsed = Number.parseInt(trimmed, 10);
+ if (Number.isSafeInteger(parsed) && parsed > 0) {
+ return parsed;
+ }
+ }
+ }
+ return undefined;
+}
+
+/**
+ * Parse max tool denials threshold from input.
+ * Falls back to MAX_TOOL_DENIALS_DEFAULT when unset/invalid.
+ *
+ * @param {unknown} value
+ * @returns {number}
+ */
+function parseMaxToolDenialsLimit(value) {
+ return parseStrictPositiveInteger(value) ?? MAX_TOOL_DENIALS_DEFAULT;
+}
+
+/**
+ * Read a positive integer from an environment variable with fallback.
+ *
+ * @param {string} key
+ * @param {number} fallback
+ * @returns {number}
+ */
+function getEnvPositiveIntOrDefault(key, fallback) {
+ return parseStrictPositiveInteger(process.env[key]) ?? fallback;
+}
+
+/**
+ * Create a compact, human-readable permission-request summary for diagnostics.
+ * Examples: shell(git status), mcp(github.get_file_contents), url(https://example.com).
+ *
+ * @param {import("@github/copilot-sdk").PermissionRequest} request
+ * @returns {string}
+ */
+function summarizePermissionRequest(request) {
+ switch (request.kind) {
+ case "shell":
+ return `shell(${String(request.fullCommandText || "").trim() || "unknown"})`;
+ case "mcp":
+ return `mcp(${request.serverName || "unknown"}.${request.toolName || "unknown"})`;
+ case "url":
+ return `url(${request.url || "unknown"})`;
+ case "write":
+ return `write(${request.fileName || "unknown"})`;
+ case "read":
+ return `read(${request.path || "unknown"})`;
+ case "custom-tool":
+ return `custom-tool(${request.toolName || "unknown"})`;
+ default:
+ return request.kind;
+ }
+}
+
+/**
+ * @param {CopilotSDKCoreLogger | undefined} coreLogger
+ * @param {(msg: string) => void} logger
+ * @param {import("@github/copilot-sdk").PermissionRequest} request
+ */
+function logPermissionDenied(coreLogger, logger, request) {
+ const requestSummary = summarizePermissionRequest(request);
+ logger(`permission denied by workflow tool permissions: ${requestSummary}`);
+ if (coreLogger?.info) {
+ coreLogger.info(`Copilot SDK permission denied: ${requestSummary}`);
+ }
+ if (coreLogger?.warning) {
+ coreLogger.warning(`Copilot SDK permission denied by workflow tool permissions: ${requestSummary}`);
+ }
+}
+
+/**
+ * @param {string} value
+ * @returns {string}
+ */
+function normalizePermissionPath(value) {
+ return (
+ String(value || "")
+ .trim()
+ .replace(/\\/g, "/")
+ .replace(/\/+$/, "") || "/"
+ );
+}
+
+/**
+ * @param {string} shellRule
+ * @returns {string[]}
+ */
+function extractReadablePathPatternsFromShellRule(shellRule) {
+ const trimmed = String(shellRule || "").trim();
+ if (!trimmed) return [];
+
+ if (trimmed.startsWith("cat ")) {
+ return [trimmed.slice("cat ".length).trim()];
+ }
+
+ const xargsCatMatch = trimmed.match(/^xargs\s+-a\s+(\S+)\s+cat(?:\s|$)/);
+ if (xargsCatMatch) {
+ return [xargsCatMatch[1]];
+ }
+
+ const lsMatch = trimmed.match(/^ls\s+(\S+)(?:\s|$)/);
+ if (lsMatch) {
+ return [lsMatch[1]];
+ }
+
+ return [];
+}
+
+/**
+ * @param {string | undefined} requestedPath
+ * @param {string[]} allowedPathPatterns
+ * @returns {boolean}
+ */
+function isReadPathAllowedByShellRules(requestedPath, allowedPathPatterns) {
+ if (typeof requestedPath !== "string" || requestedPath.trim().length === 0) {
+ return false;
+ }
+
+ const normalizedRequestedPath = normalizePermissionPath(requestedPath);
+
+ return allowedPathPatterns.some(pattern => {
+ const normalizedPattern = normalizePermissionPath(pattern);
+ if (normalizedRequestedPath === normalizedPattern) {
+ return true;
+ }
+ return path.posix.matchesGlob(normalizedRequestedPath, normalizedPattern);
+ });
+}
+
+/**
+ * Build an SDK on-permission handler from Copilot CLI allow-tool rules.
+ * A handler is always returned so session creation consistently wires explicit
+ * permission behavior derived from configuration input.
+ *
+ * @param {CopilotSDKPermissionConfig | undefined} permissionConfig
+ * @param {import("@github/copilot-sdk").PermissionHandler} approveAll
+ * @param {{coreLogger?: CopilotSDKCoreLogger, logger?: (msg: string) => void, onDenied?: (requestSummary: string) => void}=} logOptions
+ * @returns {import("@github/copilot-sdk").PermissionHandler}
+ */
+function buildCopilotSDKPermissionHandler(permissionConfig, approveAll, logOptions) {
+ const logger = logOptions?.logger ?? (() => {});
+
+ const allowAll = permissionConfig?.allowAllTools === true;
+ const allowedTools = Array.isArray(permissionConfig?.allowedTools) ? permissionConfig.allowedTools : [];
+ const normalizedAllowedTools = allowedTools
+ .filter(tool => typeof tool === "string")
+ .map(tool => tool.trim())
+ .filter(tool => tool.length > 0);
+ const allowedToolEntries = new Set(normalizedAllowedTools);
+
+ // Keep explicit allow-all behavior when requested by config input.
+ if (allowAll || allowedToolEntries.size === 0) {
+ return approveAll;
+ }
+
+ const shellRules = [...allowedToolEntries]
+ .filter(tool => tool.startsWith("shell(") && tool.endsWith(")"))
+ .map(tool => tool.slice("shell(".length, -1).trim())
+ .filter(Boolean);
+ const readablePathPatterns = shellRules.flatMap(extractReadablePathPatternsFromShellRule);
+
+ /**
+ * Returns true if a single command identifier matches any of the shell rules.
+ *
+ * Three rule formats are recognised:
+ * - **Wildcard** (`cmd:*`) — the identifier must equal the prefix before `:*`.
+ * Example: rule `"safeoutputs:*"` matches identifier `"safeoutputs"`.
+ * - **Single-word** (`cmd`) — the identifier must equal the rule exactly.
+ * Example: rule `"ls"` matches identifier `"ls"` only.
+ * - **Full-command** (`cmd arg …`) — rules that contain a space are intentionally
+ * **not** tested here. They represent exact full-command constraints and are
+ * only meaningful when compared against the whole command text, not against
+ * individual pipeline stages.
+ *
+ * @param {string} identifier - A single command name (e.g. "ls", "git", "safeoutputs")
+ * @returns {boolean} True when any shell rule permits the identifier
+ */
+ function isIdentifierAllowedByShellRules(identifier) {
+ return shellRules.some(rule => {
+ if (rule.endsWith(":*")) {
+ const prefix = rule.slice(0, -2).trim();
+ return prefix.length > 0 && identifier === prefix;
+ }
+ if (!rule.includes(" ")) {
+ return identifier === rule;
+ }
+ return false;
+ });
+ }
+
+ /**
+ * @param {import("@github/copilot-sdk").PermissionRequest} request
+ * @returns {boolean}
+ */
+ function isAllowed(request) {
+ switch (request.kind) {
+ case "shell": {
+ if (allowedToolEntries.has("shell")) return true;
+ const commandIdentifiers = Array.isArray(request.commands) ? request.commands.map(cmd => cmd?.identifier).filter(Boolean) : [];
+ const fullCommand = String(request.fullCommandText || "").trim();
+
+ // Primary path: the SDK provided command identifiers.
+ // Use original matching logic: single-word and :* rules match identifiers,
+ // rules with spaces are compared against the full command text.
+ if (commandIdentifiers.length > 0) {
+ return shellRules.some(rule => {
+ if (rule.endsWith(":*")) {
+ const prefix = rule.slice(0, -2).trim();
+ return prefix.length > 0 && commandIdentifiers.includes(prefix);
+ }
+ if (!rule.includes(" ")) {
+ return commandIdentifiers.includes(rule);
+ }
+ return fullCommand === rule;
+ });
+ }
+
+ // Fallback path: SDK did not supply command identifiers (common for complex
+ // piped / chained commands such as `ls /tmp && cat file.json || echo "done"`).
+ // Parse fullCommandText to extract the executable name from each pipeline
+ // stage and verify that every stage is individually allowed.
+ if (fullCommand) {
+ const parsedNames = extractCommandNamesFromPipeline(fullCommand);
+
+ if (parsedNames.length > 1) {
+ // Multi-stage pipeline: ALL stages must be individually allowed.
+ // Exact full-command rules (with spaces) do not apply to individual
+ // pipeline stages — only single-word and :* prefix rules.
+ return parsedNames.every(name => isIdentifierAllowedByShellRules(name));
+ }
+
+ if (parsedNames.length === 1) {
+ // Single parsed command: apply the same logic as for a single SDK identifier,
+ // including exact full-command rule matching for rules that contain spaces.
+ const [name] = parsedNames;
+ return shellRules.some(rule => {
+ if (rule.endsWith(":*")) {
+ const prefix = rule.slice(0, -2).trim();
+ return prefix.length > 0 && name === prefix;
+ }
+ if (!rule.includes(" ")) {
+ return name === rule;
+ }
+ return fullCommand === rule;
+ });
+ }
+
+ // Could not extract any command names (e.g. complex subshell-only command).
+ // Last resort: try an exact full-command match against rules with spaces.
+ return shellRules.some(rule => rule.includes(" ") && !rule.endsWith(":*") && fullCommand === rule);
+ }
+
+ return false;
+ }
+ case "write":
+ return allowedToolEntries.has("write");
+ case "read":
+ return allowedToolEntries.has("read") || allowedToolEntries.has("shell") || isReadPathAllowedByShellRules(request.path, readablePathPatterns);
+ case "url":
+ return allowedToolEntries.has("web_fetch");
+ case "mcp":
+ // Server-only entries (for example: "github") allow all tools from that server.
+ // Server+tool entries (for example: "github(get_file_contents)") allow only that tool.
+ return allowedToolEntries.has(request.serverName) || allowedToolEntries.has(`${request.serverName}(${request.toolName})`);
+ case "custom-tool":
+ return allowedToolEntries.has(request.toolName);
+ default:
+ return false;
+ }
+ }
+
+ return request => {
+ if (isAllowed(request)) {
+ return { kind: "approve-once" };
+ }
+ const requestSummary = summarizePermissionRequest(request);
+ logPermissionDenied(logOptions?.coreLogger, logger, request);
+ if (logOptions?.onDenied) {
+ logOptions.onDenied(requestSummary);
+ }
+ return { kind: "reject", feedback: "Tool invocation is not allowed by workflow tool permissions." };
+ };
+}
+
+/**
+ * Parse a CopilotSDKPermissionConfig from a JSON-encoded sidecar args array.
+ *
+ * Extracts --allow-tool values and the --allow-all-tools flag from the raw
+ * GH_AW_COPILOT_SDK_SERVER_ARGS string that the Go engine writes. Returns
+ * undefined when no permission-related flags are present so the session
+ * on-permission handler can interpret config absence as unrestricted behavior.
+ *
+ * @param {string | undefined} serverArgsJson - Raw JSON value of GH_AW_COPILOT_SDK_SERVER_ARGS
+ * @returns {CopilotSDKPermissionConfig | undefined}
+ */
+function parsePermissionConfigFromServerArgs(serverArgsJson) {
+ if (!serverArgsJson) {
+ return undefined;
+ }
+ /** @type {unknown} */
+ let parsed;
+ try {
+ parsed = JSON.parse(serverArgsJson);
+ } catch {
+ return undefined;
+ }
+ if (!Array.isArray(parsed)) {
+ return undefined;
+ }
+ const args = /** @type {unknown[]} */ parsed;
+
+ // --allow-all-tools takes precedence: the sidecar was launched with blanket
+ // tool approval, so the driver should mirror that policy.
+ if (args.includes("--allow-all-tools")) {
+ return { allowAllTools: true };
+ }
+
+ // Collect the value of every --allow-tool pair.
+ /** @type {string[]} */
+ const allowedTools = [];
+ for (let i = 0; i < args.length - 1; i++) {
+ if (args[i] === "--allow-tool" && typeof args[i + 1] === "string") {
+ allowedTools.push(/** @type {string} */ args[i + 1]);
+ i += 1; // consume the value so it is not re-examined as a flag
+ }
+ }
+
+ return allowedTools.length > 0 ? { allowedTools } : undefined;
+}
+
+module.exports = {
+ MAX_TOOL_DENIALS_DEFAULT,
+ parseStrictPositiveInteger,
+ parseMaxToolDenialsLimit,
+ getEnvPositiveIntOrDefault,
+ summarizePermissionRequest,
+ logPermissionDenied,
+ normalizePermissionPath,
+ extractReadablePathPatternsFromShellRule,
+ isReadPathAllowedByShellRules,
+ buildCopilotSDKPermissionHandler,
+ parsePermissionConfigFromServerArgs,
+};
diff --git a/setup/js/copilot_sdk_session.cjs b/setup/js/copilot_sdk_session.cjs
new file mode 100644
index 00000000..c8510abb
--- /dev/null
+++ b/setup/js/copilot_sdk_session.cjs
@@ -0,0 +1,341 @@
+// @ts-check
+
+/**
+ * Copilot SDK Session Runner
+ *
+ * Runs a single Copilot agentic session using the @github/copilot-sdk.
+ * Serializes all SDK session events to a JSONL file so that
+ * unified_timeline.cjs can render them in the step summary.
+ *
+ * Event mapping:
+ * SDK "user.message" → JSONL "user.message"
+ * SDK "tool.execution_start" → JSONL "tool.execution_start" (toolName, mcpServerName)
+ * SDK "tool.execution_complete" → JSONL "tool.execution_complete" (toolName, mcpServerName, success)
+ * SDK "assistant.message" → JSONL "assistant.message" (content)
+ *
+ * The JSONL file is written to:
+ * /tmp/gh-aw/sandbox/agent/logs/copilot-session-state/{sessionId}/events.jsonl
+ * which mirrors the path that copy_copilot_session_state.sh produces and that
+ * unified_timeline.cjs reads.
+ *
+ * Consumed directly by copilot_sdk_driver.cjs (the built-in gh-aw driver) and
+ * available to any custom driver that wants the same session lifecycle and JSONL
+ * telemetry without duplicating the implementation.
+ */
+
+"use strict";
+
+const fs = require("fs");
+const path = require("path");
+const os = require("os");
+const { buildCopilotSDKPermissionHandler, getEnvPositiveIntOrDefault, parseMaxToolDenialsLimit, MAX_TOOL_DENIALS_DEFAULT } = require("./copilot_sdk_permissions.cjs");
+
+// Default timeout for a single sendAndWait call: 10 minutes.
+// This is intentionally generous — the headless Copilot CLI has its own internal
+// timeouts for individual tool calls and model inference.
+// Override via the COPILOT_SDK_SEND_TIMEOUT_MS environment variable.
+const SDK_SEND_TIMEOUT_MS_DEFAULT = 10 * 60 * 1000;
+
+/**
+ * Extract the prompt text from a resolved args array.
+ * Looks for the first occurrence of "-p " or "--prompt ".
+ *
+ * @param {string[]} args - Resolved args (after resolvePromptFileArgs has run).
+ * @returns {string | null} The prompt text, or null if not found.
+ */
+function extractPromptFromArgs(args) {
+ for (let i = 0; i < args.length - 1; i++) {
+ if (args[i] === "-p" || args[i] === "--prompt") {
+ return args[i + 1];
+ }
+ }
+ return null;
+}
+
+/**
+ * Run a Copilot agentic session using the @github/copilot-sdk.
+ *
+ * Connects to the already-running headless Copilot CLI server at sdkUri, creates
+ * a session, sends the prompt, waits for the session to go idle, and returns a
+ * result shape that mirrors what runProcess() returns so that callers can treat
+ * both modes uniformly.
+ *
+ * All SDK events are serialised to a JSONL file under the session state directory
+ * so that unified_timeline.cjs can render them in the step summary.
+ *
+ * @param {{
+ * sdkUri: string,
+ * prompt: string,
+ * logger: (msg: string) => void,
+ * attempt?: number,
+ * model?: string,
+ * connectionToken?: string,
+ * provider?: import("@github/copilot-sdk").ProviderConfig,
+ * maxToolDenials?: number | string,
+ * permissionConfig?: {
+ * allowAllTools?: boolean,
+ * allowedTools?: string[],
+ * },
+ * coreLogger?: import("./copilot_sdk_permissions.cjs").CopilotSDKCoreLogger,
+ * sdkModule?: {
+ * CopilotClient: typeof import("@github/copilot-sdk").CopilotClient,
+ * RuntimeConnection: typeof import("@github/copilot-sdk").RuntimeConnection,
+ * approveAll: typeof import("@github/copilot-sdk").approveAll
+ * },
+ * }} options
+ * @returns {Promise<{exitCode: number, output: string, hasOutput: boolean, durationMs: number}>}
+ */
+async function runWithCopilotSDK({ sdkUri, prompt, logger, attempt = 0, model, connectionToken, provider, maxToolDenials, permissionConfig, coreLogger, sdkModule }) {
+ // Lazy-require to avoid loading the SDK when it is not needed.
+ // The SDK is large and has side-effects on import (worker threads, etc.).
+ const { CopilotClient, RuntimeConnection, approveAll } = sdkModule ?? require("@github/copilot-sdk");
+
+ const startTime = Date.now();
+ let output = "";
+ let hasOutput = false;
+
+ const log = msg => logger(`[sdk-driver] ${msg}`);
+ log(`attempt ${attempt + 1}: connecting to Copilot SDK at ${sdkUri}`);
+ let maxToolDenialsLimit = MAX_TOOL_DENIALS_DEFAULT;
+ if (maxToolDenials === undefined) {
+ maxToolDenialsLimit = getEnvPositiveIntOrDefault("GH_AW_MAX_TOOL_DENIALS", MAX_TOOL_DENIALS_DEFAULT);
+ } else {
+ maxToolDenialsLimit = parseMaxToolDenialsLimit(maxToolDenials);
+ }
+ log(`max-tool-denials threshold: ${maxToolDenialsLimit}`);
+
+ // Session state directory — mirrors the target path used by unified_timeline.cjs.
+ // /tmp/gh-aw/sandbox/agent/logs/copilot-session-state/{sessionId}/events.jsonl
+ const sessionStateBase = path.join(os.tmpdir(), "gh-aw", "sandbox", "agent", "logs", "copilot-session-state");
+
+ /** @type {ReadonlyArray>} */
+ const VALID_LOG_LEVELS = ["none", "error", "warning", "info", "debug", "all"];
+ const rawLogLevel = process.env.COPILOT_SDK_LOG_LEVEL ?? "";
+ /**
+ * @param {string} value
+ * @returns {value is NonNullable}
+ */
+ const isValidLogLevel = value => {
+ /** @type {readonly string[]} */
+ const validLogLevels = VALID_LOG_LEVELS;
+ return validLogLevels.includes(value);
+ };
+ /** @type {import("@github/copilot-sdk").CopilotClientOptions["logLevel"]} */
+ const logLevel = isValidLogLevel(rawLogLevel) ? rawLogLevel : "warning";
+
+ const connection = RuntimeConnection.forUri(sdkUri, {
+ connectionToken,
+ });
+ const client = new CopilotClient({
+ connection,
+ workingDirectory: process.env.GITHUB_WORKSPACE || process.cwd(),
+ logLevel,
+ });
+ let session = null;
+ /** @type {fs.WriteStream | null} */
+ let eventsStream = null;
+ let clientStarted = false;
+ let toolDenialCount = 0;
+ let catastrophicToolDenialsError = null;
+ let catastrophicToolDenialsTriggered = false;
+
+ /**
+ * Best-effort write of a driver-level event to events.jsonl and stderr.
+ * @param {string} type
+ * @param {object} data
+ */
+ function writeDriverEvent(type, data) {
+ const entry = { type, timestamp: new Date().toISOString(), data };
+ const jsonl = JSON.stringify(entry) + "\n";
+ if (eventsStream) {
+ eventsStream.write(jsonl);
+ }
+ process.stderr.write(jsonl);
+ }
+
+ /**
+ * @param {string} reason
+ */
+ function recordToolDenial(reason) {
+ toolDenialCount += 1;
+ log(`tool denial ${toolDenialCount}/${maxToolDenialsLimit}: ${reason}`);
+ if (catastrophicToolDenialsTriggered || toolDenialCount < maxToolDenialsLimit) {
+ return;
+ }
+ catastrophicToolDenialsTriggered = true;
+ catastrophicToolDenialsError = new Error(`max tool denials threshold reached (${toolDenialCount}/${maxToolDenialsLimit})`);
+ writeDriverEvent("guard.tool_denials_exceeded", {
+ denialCount: toolDenialCount,
+ threshold: maxToolDenialsLimit,
+ reason,
+ });
+ log(`${catastrophicToolDenialsError.message}; stopping SDK session early`);
+ if (session) {
+ void session.disconnect().catch(() => {
+ // best-effort early stop
+ });
+ }
+ }
+
+ try {
+ await client.start();
+ clientStarted = true;
+ log("client started");
+
+ /**
+ * Build the session on-permission handler from configuration input.
+ * @type {import("@github/copilot-sdk").PermissionHandler}
+ */
+ const onPermissionRequest = buildCopilotSDKPermissionHandler(permissionConfig, approveAll, {
+ coreLogger,
+ logger: log,
+ onDenied: requestSummary => recordToolDenial(`permission denied: ${requestSummary}`),
+ });
+
+ /** @type {import("@github/copilot-sdk").SessionConfig} */
+ const sessionConfig = {
+ model: model || process.env.COPILOT_MODEL || undefined,
+ provider,
+ onPermissionRequest,
+ };
+ session = await client.createSession(sessionConfig);
+ log(`session created: sessionId=${session.sessionId}`);
+
+ // Prepare JSONL output file for this session.
+ const sessionDir = path.join(sessionStateBase, session.sessionId);
+ fs.mkdirSync(sessionDir, { recursive: true });
+ const eventsPath = path.join(sessionDir, "events.jsonl");
+ eventsStream = fs.createWriteStream(eventsPath, { flags: "a" });
+ // Snapshot to a non-null local for closure-safe writes (JSDoc nullability narrowing).
+ const stream = eventsStream;
+ log(`serialising SDK events to ${eventsPath}`);
+
+ /**
+ * Map from toolCallId → {toolName, mcpServerName} so that tool.execution_complete
+ * events (which carry no mcpServerName) can be enriched from the matching start event.
+ * @type {Map}
+ */
+ const pendingToolCalls = new Map();
+
+ /**
+ * Write one JSONL entry to the events file and stderr.
+ * Uses the event's own ISO-8601 timestamp when available.
+ *
+ * @param {string} type
+ * @param {object} data
+ * @param {string | undefined} [timestamp]
+ */
+ function writeEvent(type, data, timestamp) {
+ const entry = { type, timestamp: timestamp ?? new Date().toISOString(), data };
+ const jsonl = JSON.stringify(entry) + "\n";
+ stream.write(jsonl);
+ process.stderr.write(jsonl);
+ }
+
+ // Subscribe to all session events and serialise the ones we care about.
+ session.on(event => {
+ // Skip transient events that are not persisted by the server.
+ if (event.ephemeral) return;
+
+ switch (event.type) {
+ case "user.message":
+ writeEvent("user.message", {}, event.timestamp);
+ break;
+
+ case "tool.execution_start": {
+ const toolName = event.data?.toolName ?? "unknown";
+ const mcpServerName = event.data?.mcpServerName ?? "";
+ const toolCallId = event.data?.toolCallId;
+ if (toolCallId) {
+ pendingToolCalls.set(toolCallId, { toolName, mcpServerName });
+ }
+ writeEvent("tool.execution_start", { toolName, mcpServerName }, event.timestamp);
+ break;
+ }
+
+ case "tool.execution_complete": {
+ const toolCallId = event.data?.toolCallId;
+ // Resolve toolName/mcpServerName from the matching start event when available.
+ const pending = toolCallId ? pendingToolCalls.get(toolCallId) : undefined;
+ const toolName = pending?.toolName ?? event.data?.toolDescription?.name ?? "unknown";
+ const mcpServerName = pending?.mcpServerName ?? "";
+ if (toolCallId) pendingToolCalls.delete(toolCallId);
+ const success = event.data?.success ?? !event.data?.error;
+ // max-tool-denials intentionally tracks permission denials only.
+ // Tool execution failures are still logged, but do not increment the guardrail counter.
+ writeEvent("tool.execution_complete", { toolName, mcpServerName, success }, event.timestamp);
+ break;
+ }
+
+ case "assistant.message": {
+ const content = event.data?.content ?? "";
+ if (content) {
+ hasOutput = true;
+ output += content;
+ }
+ writeEvent("assistant.message", { content }, event.timestamp);
+ break;
+ }
+
+ default:
+ // Other event types are not consumed by unified_timeline.cjs; skip them.
+ break;
+ }
+ });
+
+ log("sending prompt...");
+ const sendTimeoutMs = getEnvPositiveIntOrDefault("COPILOT_SDK_SEND_TIMEOUT_MS", SDK_SEND_TIMEOUT_MS_DEFAULT);
+ const result = await session.sendAndWait({ prompt }, sendTimeoutMs);
+
+ if (catastrophicToolDenialsError) {
+ throw catastrophicToolDenialsError;
+ }
+
+ // sendAndWait returns the last assistant.message event; capture its content
+ // as a fallback in case the on() handler missed it.
+ if (result && !hasOutput) {
+ const content = result.data?.content ?? "";
+ if (content) {
+ output = content;
+ hasOutput = true;
+ }
+ }
+
+ const durationMs = Date.now() - startTime;
+ log(`session completed: hasOutput=${hasOutput} durationMs=${durationMs}`);
+
+ return { exitCode: 0, output, hasOutput, durationMs };
+ } catch (err) {
+ const durationMs = Date.now() - startTime;
+ const failure = catastrophicToolDenialsError ?? (err instanceof Error ? err : new Error(String(err)));
+ log(`error: ${failure.message}`);
+ return {
+ exitCode: 1,
+ output: failure.message,
+ hasOutput: false,
+ durationMs,
+ };
+ } finally {
+ // Snapshot for null-safe cleanup in this scope.
+ const stream = eventsStream;
+ if (stream) {
+ await new Promise(resolve => stream.end(resolve));
+ }
+ if (session) {
+ try {
+ await session.disconnect();
+ } catch {
+ // best-effort cleanup
+ }
+ }
+ if (clientStarted) {
+ try {
+ await client.stop();
+ } catch {
+ // best-effort cleanup
+ }
+ }
+ }
+}
+
+module.exports = { SDK_SEND_TIMEOUT_MS_DEFAULT, extractPromptFromArgs, runWithCopilotSDK };
diff --git a/setup/js/create_pull_request.cjs b/setup/js/create_pull_request.cjs
index 66d930cf..ed854bbe 100644
--- a/setup/js/create_pull_request.cjs
+++ b/setup/js/create_pull_request.cjs
@@ -37,6 +37,7 @@ const { withRetry, RATE_LIMIT_RETRY_CONFIG } = require("./error_recovery.cjs");
const { findAgent, getIssueDetails, assignAgentToIssue } = require("./assign_agent_helpers.cjs");
const { ensureFullHistoryForBundle, extractBundlePrerequisiteCommits, isShallowOrSparseCheckout, linearizeRangeAsCommit } = require("./git_helpers.cjs");
const { parseDiffGitHeader: parseDiffGitHeaderPaths, extractDiffGitHeaderEntries } = require("./patch_path_helpers.cjs");
+const { resolveTransportPaths } = require("./resolve_transport_paths.cjs");
const { resolveAllowedMentionsFromPayload } = require("./resolve_mentions_from_payload.cjs");
const {
MANAGED_FALLBACK_ISSUE_LABEL,
@@ -182,15 +183,16 @@ async function tryRecoverGitAmAddAddConflict(execApi) {
* @param {string} branchName - Target branch name
* @param {string} originalAgentBranch - Original source branch name from the agent, if different
* @param {{ exec: Function, getExecOutput: Function }} execApi - GitHub Actions exec API
+ * @param {string} [baseBranch] - Base branch name (used for iterative shallow-clone deepening)
* @returns {Promise}
*/
-async function applyBundleToBranch(bundleFilePath, branchName, originalAgentBranch, execApi) {
+async function applyBundleToBranch(bundleFilePath, branchName, originalAgentBranch, execApi, baseBranch) {
let bundleBranchRef = `refs/heads/${originalAgentBranch || branchName}`;
const bundleTargetRef = `refs/heads/${branchName}`;
const bundleTempRef = createBundleTempRef(branchName);
try {
- await ensureFullHistoryForBundle(execApi);
+ await ensureFullHistoryForBundle(execApi, {}, { baseRef: baseBranch, bundleFilePath });
core.info(`Applying bundle ${bundleFilePath} to ${bundleTargetRef} using temp ref ${bundleTempRef} from ${bundleBranchRef}`);
// Fetch from bundle into a temporary ref, then update the target branch.
@@ -796,12 +798,15 @@ async function main(config = {}) {
core.info(`Processing create_pull_request: title=${pullRequestItem.title || "No title"}, bodyLength=${pullRequestItem.body?.length || 0}`);
- // Determine the patch file path from the message (set by the MCP server handler)
- const patchFilePath = pullRequestItem.patch_path;
+ // Determine the patch and bundle file paths. The MCP server sets these on
+ // the entry it writes, but the validation step strips them as a defense
+ // against agent-forged values. Recover them by re-deriving from `branch`.
+ const transportPaths = resolveTransportPaths(pullRequestItem, defaultTargetRepo);
+ const patchFilePath = transportPaths.patchPath;
core.info(`Patch file path: ${patchFilePath || "(not set)"}`);
// Determine the bundle file path from the message (set when patch-format: bundle is configured)
- const bundleFilePath = pullRequestItem.bundle_path;
+ const bundleFilePath = transportPaths.bundlePath;
if (bundleFilePath) {
core.info(`Bundle file path: ${bundleFilePath}`);
}
@@ -1478,7 +1483,7 @@ async function main(config = {}) {
// unlike git format-patch which flattens history and drops merge resolution content.
core.info(`Applying changes from bundle: ${bundleFilePath}`);
try {
- await applyBundleToBranch(bundleFilePath, branchName, originalAgentBranch, exec);
+ await applyBundleToBranch(bundleFilePath, branchName, originalAgentBranch, exec, baseBranch);
} catch (bundleError) {
core.error(`Failed to apply bundle: ${bundleError instanceof Error ? bundleError.message : String(bundleError)}`);
return { success: false, error: "Failed to apply bundle" };
@@ -1502,6 +1507,7 @@ async function main(config = {}) {
signedCommits,
resolvedTemporaryIds,
currentRepo: itemRepo,
+ validationConfig: config,
});
core.info("Changes pushed to branch (from bundle)");
@@ -1534,6 +1540,7 @@ async function main(config = {}) {
signedCommits,
resolvedTemporaryIds,
currentRepo: itemRepo,
+ validationConfig: config,
});
core.info("Changes pushed to branch after bundle rewrite retry");
@@ -1665,7 +1672,7 @@ gh pr create --title '${title}' --base ${baseBranch} --head ${branchName} --repo
core.info(`Created new branch from base: ${branchName} (${branchBaseRef})`);
// Apply the patch using git CLI (skip if empty)
- if (!isEmpty) {
+ if (!isEmpty && patchFilePath) {
let postApplyBaseRef = null;
const capturePostApplyBaseRef = async () => {
const headResult = await exec.getExecOutput("git", ["rev-parse", "HEAD"]);
@@ -1865,6 +1872,7 @@ gh pr create --title '${title}' --base ${baseBranch} --head ${branchName} --repo
signedCommits,
resolvedTemporaryIds,
currentRepo: itemRepo,
+ validationConfig: config,
});
core.info("Changes pushed to branch");
@@ -2013,6 +2021,7 @@ ${patchPreview}`;
signedCommits,
resolvedTemporaryIds,
currentRepo: itemRepo,
+ validationConfig: config,
});
core.info("Empty branch pushed successfully");
diff --git a/setup/js/effective_tokens.cjs b/setup/js/effective_tokens.cjs
index 21343069..074b6de6 100644
--- a/setup/js/effective_tokens.cjs
+++ b/setup/js/effective_tokens.cjs
@@ -169,41 +169,18 @@ function reduceModelNameToIdentifier(modelName) {
}
/**
- * Returns a compact monochrome Unicode symbol-prefixed alias for a model name.
- * Uses distinct symbols for each compact model kind so aliases remain scannable.
+ * Returns a compact alias for a model name (the simplified identifier without a symbol prefix).
*
* Examples:
- * - claude-sonnet-4.6 -> ◉ sonnet46
- * - gpt-5.5 -> ■ gpt55
- * - gemini-2.5-pro -> ★ gem25pro
+ * - claude-sonnet-4.6 -> sonnet46
+ * - gpt-5.5 -> gpt55
+ * - gemini-2.5-pro -> gem25pro
*
* @param {string|undefined|null} modelName
* @returns {string}
*/
function formatModelEmojiAlias(modelName) {
- const identifier = reduceModelNameToIdentifier(modelName);
- if (!identifier) return "";
-
- const normalized = String(modelName || "")
- .trim()
- .toLowerCase();
-
- let emoji = "○";
- if (/sonnet/.test(normalized)) {
- emoji = "◉";
- } else if (/opus/.test(normalized)) {
- emoji = "◆";
- } else if (/haiku/.test(normalized)) {
- emoji = "▲";
- } else if (/^o[0-9](?:$|[-_])/.test(normalized)) {
- emoji = "●";
- } else if (/gpt|openai/.test(normalized)) {
- emoji = "■";
- } else if (/gemini|gemma|google|^gem[0-9]/.test(normalized)) {
- emoji = "★";
- }
-
- return `${emoji} ${identifier}`;
+ return reduceModelNameToIdentifier(modelName);
}
/**
@@ -415,7 +392,7 @@ function buildETComputationTable(effectiveTokens, tokenUsageDetails = null) {
const lines = [];
lines.push("");
- lines.push("ET computation details
");
+ lines.push("AIC computation details
");
lines.push("");
if (tokenUsageMarkdown) {
diff --git a/setup/js/effective_tokens_context.cjs b/setup/js/effective_tokens_context.cjs
index 142bc124..88aaad92 100644
--- a/setup/js/effective_tokens_context.cjs
+++ b/setup/js/effective_tokens_context.cjs
@@ -9,22 +9,21 @@ const MAX_EFFECTIVE_TOKENS_FIELDS = new Set(["max_effective_tokens", "maxEffecti
const EFFECTIVE_TOKENS_FIELDS = new Set(["effective_tokens", "effectiveTokens"]);
const EFFECTIVE_TOKENS_RATE_LIMIT_ERROR_FIELDS = new Set(["effective_tokens_rate_limit_error", "effectiveTokensRateLimitError"]);
const EFFECTIVE_TOKENS_RATE_LIMIT_TEXT_FIELDS = new Set(["error", "message", "reason", "details", "detail"]);
-// Effective-token rate-limit indicators seen in runtime/audit payload text, e.g.:
-// - "effective_tokens limit exceeded"
-// - "rate limit ... effective tokens"
-// - "429 too many requests ... ET budget"
-// Keep these patterns permissive because providers vary wording across error payloads.
const EFFECTIVE_TOKENS_RATE_LIMIT_PATTERNS = [
/effective[\s_-]*tokens?.*(?:rate[\s-]*limit|limit exceeded|budget exceeded|exceeded)/i,
/(?:rate[\s-]*limit|too many requests).*(?:effective[\s_-]*tokens?|et budget)/i,
/\b429\b[\s\S]{0,120}(?:rate[\s-]*limit|too many requests|effective[\s_-]*tokens?|et budget)/i,
];
+
+const MAX_AI_CREDITS_FIELDS = new Set(["max_ai_credits", "maxAiCredits"]);
+const AI_CREDITS_FIELDS = new Set(["ai_credits", "aiCredits"]);
+const AI_CREDITS_RATE_LIMIT_ERROR_FIELDS = new Set(["ai_credits_rate_limit_error", "aiCreditsRateLimitError"]);
+const AI_CREDITS_RATE_LIMIT_TEXT_FIELDS = new Set(["error", "message", "reason", "details", "detail", "type", "code"]);
+const AI_CREDITS_RATE_LIMIT_PATTERNS = [/ai[\s_-]*credits?.*(?:rate[\s-]*limit|limit exceeded|budget exceeded|exceeded)/i, /(?:rate[\s-]*limit|too many requests).*(?:ai[\s_-]*credits?)/i, /\bai_credits_limit_exceeded\b/i];
+
const AWF_REFLECT_RELATIVE_PATH = path.join("sandbox", "firewall", "awf-reflect.json");
-/**
- * @param {unknown} value
- * @returns {string}
- */
+/** @param {unknown} value */
function parsePositiveIntegerString(value) {
if (typeof value === "number" && Number.isFinite(value) && value > 0) {
return String(Math.trunc(value));
@@ -35,19 +34,22 @@ function parsePositiveIntegerString(value) {
return "";
}
-/**
- * Compare two integer strings using BigInt.
- * Returns false when either value is missing or cannot be parsed as an integer.
- *
- * @param {string} left
- * @param {string} right
- * @returns {boolean}
- */
-function isIntegerStringGreaterThanOrEqual(left, right) {
- if (!left || !right) {
- return false;
+/** @param {unknown} value */
+function parsePositiveNumberString(value) {
+ if (typeof value === "number" && Number.isFinite(value) && value > 0) {
+ return String(value);
+ }
+ if (typeof value === "string") {
+ const trimmed = value.trim();
+ if (trimmed === "") return "";
+ const parsed = Number.parseFloat(trimmed);
+ if (Number.isFinite(parsed) && parsed > 0) return trimmed;
}
+ return "";
+}
+function isIntegerStringGreaterThanOrEqual(left, right) {
+ if (!left || !right) return false;
try {
return BigInt(left) >= BigInt(right);
} catch {
@@ -55,56 +57,36 @@ function isIntegerStringGreaterThanOrEqual(left, right) {
}
}
-/**
- * Decide whether an ET rate-limit signal should be surfaced as budget exhaustion.
- * A missing signal always means "no". When the signal is present but one of the
- * token counts is unavailable, keep reporting the condition; otherwise require the
- * effective-token count to meet or exceed the configured max.
- *
- * @param {boolean} hasRateLimitSignal
- * @param {string} effectiveTokens
- * @param {string} maxEffectiveTokens
- * @returns {boolean}
- */
-function shouldReportEffectiveTokensRateLimitError(hasRateLimitSignal, effectiveTokens, maxEffectiveTokens) {
- if (!hasRateLimitSignal) {
- return false;
- }
-
- if (!effectiveTokens || !maxEffectiveTokens) {
- // Conservative fallback: when a rate-limit signal exists but the numeric budget
- // values are unavailable, keep surfacing the ET failure instead of suppressing it.
- return true;
- }
+function isNumberStringGreaterThanOrEqual(left, right) {
+ if (!left || !right) return false;
+ const leftNumber = Number.parseFloat(left);
+ const rightNumber = Number.parseFloat(right);
+ return Number.isFinite(leftNumber) && Number.isFinite(rightNumber) && leftNumber >= rightNumber;
+}
+function shouldReportEffectiveTokensRateLimitError(hasRateLimitSignal, effectiveTokens, maxEffectiveTokens) {
+ if (!hasRateLimitSignal) return false;
+ if (!effectiveTokens || !maxEffectiveTokens) return true;
return isIntegerStringGreaterThanOrEqual(effectiveTokens, maxEffectiveTokens);
}
-/**
- * @param {unknown} value
- * @returns {boolean}
- */
+function shouldReportAICreditsRateLimitError(hasRateLimitSignal, aiCredits, maxAICredits) {
+ if (!hasRateLimitSignal) return false;
+ if (!aiCredits || !maxAICredits) return true;
+ return isNumberStringGreaterThanOrEqual(aiCredits, maxAICredits);
+}
+
+/** @param {unknown} value */
function isTrueLike(value) {
return value === true || value === "true" || value === 1 || value === "1";
}
-/**
- * Resolve the AWF firewall audit log path.
- * Newer runs write `log.jsonl`; older runs use `audit.jsonl`.
- *
- * @param {string} [auditJsonlPathOverride]
- * @returns {string}
- */
function resolveFirewallAuditLogPath(auditJsonlPathOverride) {
if (auditJsonlPathOverride) return auditJsonlPathOverride;
-
const agentOutputFile = process.env.GH_AW_AGENT_OUTPUT;
const candidateBases = [];
if (agentOutputFile) {
candidateBases.push(path.join(path.dirname(agentOutputFile), "sandbox", "firewall", "audit"));
- // AWF artifacts have used both sandbox/firewall/audit/ and sandbox/firewall/logs/
- // for JSONL exports across versions/configurations. Probe both agent-artifact-relative
- // paths first, then the equivalent absolute /tmp fallback locations below.
candidateBases.push(path.join(path.dirname(agentOutputFile), "sandbox", "firewall", "logs"));
}
candidateBases.push("/tmp/gh-aw/sandbox/firewall/audit");
@@ -116,32 +98,16 @@ function resolveFirewallAuditLogPath(auditJsonlPathOverride) {
const auditPath = path.join(base, "audit.jsonl");
if (fs.existsSync(auditPath)) return auditPath;
}
-
- // Default to the latest expected location/name.
return path.join(candidateBases[0] || "/tmp/gh-aw/sandbox/firewall/audit", "log.jsonl");
}
-/**
- * Resolve the agent stdio log path used by the conclusion job.
- *
- * @param {string} [stdioLogPathOverride]
- * @returns {string}
- */
function resolveAgentStdioLogPath(stdioLogPathOverride) {
if (stdioLogPathOverride) return stdioLogPathOverride;
const agentOutputFile = process.env.GH_AW_AGENT_OUTPUT;
- if (agentOutputFile) {
- return path.join(path.dirname(agentOutputFile), "agent-stdio.log");
- }
+ if (agentOutputFile) return path.join(path.dirname(agentOutputFile), "agent-stdio.log");
return "/tmp/gh-aw/agent-stdio.log";
}
-/**
- * Detect a hard effective-token budget rail from agent stderr/stdout logs.
- *
- * @param {string} [stdioLogPathOverride]
- * @returns {boolean}
- */
function hasMaxEffectiveTokensExceededSignal(stdioLogPathOverride) {
try {
const stdioLogPath = resolveAgentStdioLogPath(stdioLogPathOverride);
@@ -153,36 +119,18 @@ function hasMaxEffectiveTokensExceededSignal(stdioLogPathOverride) {
}
}
-/**
- * Resolve the AWF firewall reflect file path.
- *
- * @returns {string}
- */
function resolveFirewallReflectPath() {
const agentOutputFile = process.env.GH_AW_AGENT_OUTPUT;
- if (agentOutputFile) {
- return path.join(path.dirname(agentOutputFile), AWF_REFLECT_RELATIVE_PATH);
- }
+ if (agentOutputFile) return path.join(path.dirname(agentOutputFile), AWF_REFLECT_RELATIVE_PATH);
return "/tmp/gh-aw/sandbox/firewall/awf-reflect.json";
}
-/**
- * Parse effective token totals from AWF firewall reflect JSON.
- *
- * @returns {{effectiveTokens: string, maxEffectiveTokens: string}}
- */
function parseEffectiveTokensFromReflectFile() {
try {
const reflectPath = resolveFirewallReflectPath();
- if (!fs.existsSync(reflectPath)) {
- return { effectiveTokens: "", maxEffectiveTokens: "" };
- }
-
+ if (!fs.existsSync(reflectPath)) return { effectiveTokens: "", maxEffectiveTokens: "" };
const content = fs.readFileSync(reflectPath, "utf8");
- if (!content.trim()) {
- return { effectiveTokens: "", maxEffectiveTokens: "" };
- }
-
+ if (!content.trim()) return { effectiveTokens: "", maxEffectiveTokens: "" };
const parsed = JSON.parse(content);
const effectiveTokens = parsePositiveIntegerString(parsed?.effective_tokens?.total_effective_tokens);
const maxEffectiveTokens = parsePositiveEffectiveTokenLimitString(parsed?.effective_tokens?.max_effective_tokens);
@@ -192,17 +140,8 @@ function parseEffectiveTokensFromReflectFile() {
}
}
-/**
- * Parse max effective tokens from a single AWF audit log entry object.
- * Accepts both snake_case and camelCase field names.
- *
- * @param {unknown} entry
- * @returns {string}
- */
function parseMaxEffectiveTokensFromAuditEntry(entry) {
if (!entry || typeof entry !== "object") return "";
-
- /** @type {unknown[]} */
const stack = [entry];
while (stack.length > 0) {
const node = stack.pop();
@@ -212,81 +151,85 @@ function parseMaxEffectiveTokensFromAuditEntry(entry) {
const parsed = parsePositiveEffectiveTokenLimitString(value);
if (parsed) return parsed;
}
- if (value && typeof value === "object") {
- stack.push(value);
- }
+ if (value && typeof value === "object") stack.push(value);
}
}
+ return "";
+}
+function parseMaxAICreditsFromAuditEntry(entry) {
+ if (!entry || typeof entry !== "object") return "";
+ const stack = [entry];
+ while (stack.length > 0) {
+ const node = stack.pop();
+ if (!node || typeof node !== "object") continue;
+ for (const [key, value] of Object.entries(node)) {
+ if (MAX_AI_CREDITS_FIELDS.has(key)) {
+ const parsed = parsePositiveNumberString(value);
+ if (parsed) return parsed;
+ }
+ if (value && typeof value === "object") stack.push(value);
+ }
+ }
return "";
}
-/**
- * Parse effective token error metadata from a single AWF audit log entry object.
- * Accepts both snake_case and camelCase field names.
- *
- * @param {unknown} entry
- * @returns {{effectiveTokens: string, rateLimitError: boolean}}
- */
function parseEffectiveTokensErrorInfoFromAuditEntry(entry) {
if (!entry || typeof entry !== "object") return { effectiveTokens: "", rateLimitError: false };
-
- /** @type {unknown[]} */
const stack = [entry];
let effectiveTokens = "";
let rateLimitError = false;
-
while (stack.length > 0) {
const node = stack.pop();
if (!node || typeof node !== "object") continue;
-
for (const [key, value] of Object.entries(node)) {
if (EFFECTIVE_TOKENS_FIELDS.has(key)) {
const parsed = parsePositiveIntegerString(value);
if (parsed) effectiveTokens = parsed;
}
-
- if (EFFECTIVE_TOKENS_RATE_LIMIT_ERROR_FIELDS.has(key)) {
- if (isTrueLike(value)) {
- rateLimitError = true;
- }
- }
-
+ if (EFFECTIVE_TOKENS_RATE_LIMIT_ERROR_FIELDS.has(key) && isTrueLike(value)) rateLimitError = true;
if (EFFECTIVE_TOKENS_RATE_LIMIT_TEXT_FIELDS.has(key) && typeof value === "string") {
- if (EFFECTIVE_TOKENS_RATE_LIMIT_PATTERNS.some(pattern => pattern.test(value))) {
- rateLimitError = true;
- }
+ if (EFFECTIVE_TOKENS_RATE_LIMIT_PATTERNS.some(pattern => pattern.test(value))) rateLimitError = true;
}
+ if (value && typeof value === "object") stack.push(value);
+ }
+ }
+ return { effectiveTokens, rateLimitError };
+}
- if (value && typeof value === "object") {
- stack.push(value);
+function parseAICreditsErrorInfoFromAuditEntry(entry) {
+ if (!entry || typeof entry !== "object") return { aiCredits: "", rateLimitError: false };
+ const stack = [entry];
+ let aiCredits = "";
+ let rateLimitError = false;
+ while (stack.length > 0) {
+ const node = stack.pop();
+ if (!node || typeof node !== "object") continue;
+ for (const [key, value] of Object.entries(node)) {
+ if (AI_CREDITS_FIELDS.has(key)) {
+ const parsed = parsePositiveNumberString(value);
+ if (parsed) aiCredits = parsed;
}
+ if (AI_CREDITS_RATE_LIMIT_ERROR_FIELDS.has(key) && isTrueLike(value)) rateLimitError = true;
+ if (AI_CREDITS_RATE_LIMIT_TEXT_FIELDS.has(key) && typeof value === "string") {
+ if (AI_CREDITS_RATE_LIMIT_PATTERNS.some(pattern => pattern.test(value))) rateLimitError = true;
+ }
+ if (value && typeof value === "object") stack.push(value);
}
}
-
- return { effectiveTokens, rateLimitError };
+ return { aiCredits, rateLimitError };
}
-/**
- * Parse max effective tokens from AWF firewall audit JSONL.
- *
- * @param {string} [auditJsonlPathOverride]
- * @returns {string}
- */
function parseMaxEffectiveTokensFromAuditLog(auditJsonlPathOverride) {
try {
const auditJsonlPath = resolveFirewallAuditLogPath(auditJsonlPathOverride);
if (!fs.existsSync(auditJsonlPath)) return "";
-
const content = fs.readFileSync(auditJsonlPath, "utf8");
- if (!content.trim()) return "";
- if (!/(?:max_effective_tokens|maxEffectiveTokens)/.test(content)) return "";
-
+ if (!content.trim() || !/(?:max_effective_tokens|maxEffectiveTokens)/.test(content)) return "";
let parsedMaxEffectiveTokens = "";
for (const line of content.split("\n")) {
const trimmed = line.trim();
if (!trimmed || trimmed[0] !== "{") continue;
-
try {
const entry = JSON.parse(trimmed);
const value = parseMaxEffectiveTokensFromAuditEntry(entry);
@@ -295,82 +238,118 @@ function parseMaxEffectiveTokensFromAuditLog(auditJsonlPathOverride) {
// ignore malformed lines
}
}
-
return parsedMaxEffectiveTokens;
} catch {
return "";
}
}
-/**
- * Parse effective token error metadata from AWF firewall audit JSONL.
- *
- * @param {string} [auditJsonlPathOverride]
- * @returns {{effectiveTokens: string, rateLimitError: boolean}}
- */
+function parseMaxAICreditsFromAuditLog(auditJsonlPathOverride) {
+ try {
+ const auditJsonlPath = resolveFirewallAuditLogPath(auditJsonlPathOverride);
+ if (!fs.existsSync(auditJsonlPath)) return "";
+ const content = fs.readFileSync(auditJsonlPath, "utf8");
+ if (!content.trim() || !/(?:max_ai_credits|maxAiCredits)/.test(content)) return "";
+ let parsedMaxAICredits = "";
+ for (const line of content.split("\n")) {
+ const trimmed = line.trim();
+ if (!trimmed || trimmed[0] !== "{") continue;
+ try {
+ const entry = JSON.parse(trimmed);
+ const value = parseMaxAICreditsFromAuditEntry(entry);
+ if (value) parsedMaxAICredits = value;
+ } catch {
+ // ignore malformed lines
+ }
+ }
+ return parsedMaxAICredits;
+ } catch {
+ return "";
+ }
+}
+
function parseEffectiveTokensErrorInfoFromAuditLog(auditJsonlPathOverride) {
try {
const auditJsonlPath = resolveFirewallAuditLogPath(auditJsonlPathOverride);
if (!fs.existsSync(auditJsonlPath)) return { effectiveTokens: "", rateLimitError: false };
-
const content = fs.readFileSync(auditJsonlPath, "utf8");
if (!content.trim()) return { effectiveTokens: "", rateLimitError: false };
-
let parsedEffectiveTokens = "";
let hasRateLimitError = false;
-
for (const line of content.split("\n")) {
const trimmed = line.trim();
if (!trimmed || trimmed[0] !== "{") continue;
-
try {
const entry = JSON.parse(trimmed);
const parsed = parseEffectiveTokensErrorInfoFromAuditEntry(entry);
- // AWF audit logs are append-only JSONL; later entries represent newer state.
if (parsed.effectiveTokens) parsedEffectiveTokens = parsed.effectiveTokens;
- // Sticky OR: any detected ET rate-limit signal is enough to report this failure mode.
if (parsed.rateLimitError) hasRateLimitError = true;
} catch {
// ignore malformed lines
}
}
-
return { effectiveTokens: parsedEffectiveTokens, rateLimitError: hasRateLimitError };
} catch {
return { effectiveTokens: "", rateLimitError: false };
}
}
-/**
- * Compute effective-token failure state with audit JSONL values preferred over env fallbacks.
- * @returns {{effectiveTokens: string, maxEffectiveTokens: string, effectiveTokensRateLimitError: boolean}}
- */
+function parseAICreditsErrorInfoFromAuditLog(auditJsonlPathOverride) {
+ try {
+ const auditJsonlPath = resolveFirewallAuditLogPath(auditJsonlPathOverride);
+ if (!fs.existsSync(auditJsonlPath)) return { aiCredits: "", rateLimitError: false };
+ const content = fs.readFileSync(auditJsonlPath, "utf8");
+ if (!content.trim()) return { aiCredits: "", rateLimitError: false };
+ let parsedAICredits = "";
+ let hasRateLimitError = false;
+ for (const line of content.split("\n")) {
+ const trimmed = line.trim();
+ if (!trimmed || trimmed[0] !== "{") continue;
+ try {
+ const entry = JSON.parse(trimmed);
+ const parsed = parseAICreditsErrorInfoFromAuditEntry(entry);
+ if (parsed.aiCredits) parsedAICredits = parsed.aiCredits;
+ if (parsed.rateLimitError) hasRateLimitError = true;
+ } catch {
+ // ignore malformed lines
+ }
+ }
+ return { aiCredits: parsedAICredits, rateLimitError: hasRateLimitError };
+ } catch {
+ return { aiCredits: "", rateLimitError: false };
+ }
+}
+
function resolveEffectiveTokensFailureState() {
const parsedEffectiveTokensErrorInfo = parseEffectiveTokensErrorInfoFromAuditLog();
const parsedEffectiveTokensFromReflect = parseEffectiveTokensFromReflectFile();
- // Treat invalid env fallbacks as missing so they do not produce misleading ET math.
const envEffectiveTokens = parsePositiveIntegerString(process.env.GH_AW_EFFECTIVE_TOKENS);
const envMaxEffectiveTokens = parsePositiveEffectiveTokenLimitString(process.env.GH_AW_MAX_EFFECTIVE_TOKENS);
const effectiveTokens = parsedEffectiveTokensErrorInfo.effectiveTokens || parsedEffectiveTokensFromReflect.effectiveTokens || envEffectiveTokens || "";
const maxEffectiveTokens = parseMaxEffectiveTokensFromAuditLog() || parsedEffectiveTokensFromReflect.maxEffectiveTokens || envMaxEffectiveTokens || "";
- // Combine effective-token-specific signals from:
- // 1) structured firewall audit JSONL metadata when available,
- // 2) the agent stdio hard-rail message seen in Copilot failures,
- // 3) the existing environment override used by upstream workflow steps.
const rawEffectiveTokensRateLimitError = parsedEffectiveTokensErrorInfo.rateLimitError || hasMaxEffectiveTokensExceededSignal() || process.env.GH_AW_EFFECTIVE_TOKENS_RATE_LIMIT_ERROR === "true";
const effectiveTokensRateLimitError = shouldReportEffectiveTokensRateLimitError(rawEffectiveTokensRateLimitError, effectiveTokens, maxEffectiveTokens);
+ return { effectiveTokens, maxEffectiveTokens, effectiveTokensRateLimitError };
+}
- return {
- effectiveTokens,
- maxEffectiveTokens,
- effectiveTokensRateLimitError,
- };
+function resolveAICreditsFailureState() {
+ const parsedAICreditsErrorInfo = parseAICreditsErrorInfoFromAuditLog();
+ const envAICredits = parsePositiveNumberString(process.env.GH_AW_AIC);
+ const envMaxAICredits = parsePositiveNumberString(process.env.GH_AW_MAX_AI_CREDITS);
+ const aiCredits = parsedAICreditsErrorInfo.aiCredits || envAICredits || "";
+ const maxAICredits = parseMaxAICreditsFromAuditLog() || envMaxAICredits || "";
+ const rawAICreditsRateLimitError = parsedAICreditsErrorInfo.rateLimitError || process.env.GH_AW_AI_CREDITS_RATE_LIMIT_ERROR === "true";
+ const aiCreditsRateLimitError = shouldReportAICreditsRateLimitError(rawAICreditsRateLimitError, aiCredits, maxAICredits);
+ return { aiCredits, maxAICredits, aiCreditsRateLimitError };
}
module.exports = {
resolveFirewallAuditLogPath,
parseMaxEffectiveTokensFromAuditLog,
parseEffectiveTokensErrorInfoFromAuditLog,
+ parseMaxAICreditsFromAuditLog,
+ parseAICreditsErrorInfoFromAuditLog,
hasMaxEffectiveTokensExceededSignal,
resolveEffectiveTokensFailureState,
+ resolveAICreditsFailureState,
};
diff --git a/setup/js/fuzz_bash_command_parser_harness.cjs b/setup/js/fuzz_bash_command_parser_harness.cjs
new file mode 100644
index 00000000..3bc48f18
--- /dev/null
+++ b/setup/js/fuzz_bash_command_parser_harness.cjs
@@ -0,0 +1,174 @@
+// @ts-check
+/**
+ * Fuzz test harness for bash_command_parser.cjs
+ *
+ * Tests security and correctness invariants of the bash pipeline parser:
+ * - splitOnPipelineOperators: splitting on &&, ||, |, ;
+ * - extractCommandName: extracts first executable word from a segment
+ * - extractCommandNamesFromPipeline: end-to-end pipeline parsing
+ *
+ * Security invariants:
+ * - The parser never throws on arbitrary input (robustness)
+ * - An empty or unparseable command always yields an empty array (safe default)
+ * - Operators inside quoted strings are never treated as separators
+ * - The result is always a (possibly empty) array of strings
+ *
+ * Used by:
+ * - fuzz_bash_command_parser_harness.test.cjs: property-based tests in vitest
+ * - Go fuzzer: reads JSON from stdin when run as main module
+ */
+
+"use strict";
+
+const { splitOnPipelineOperators, extractCommandName, extractCommandNamesFromPipeline } = require("./bash_command_parser.cjs");
+
+/**
+ * Test splitOnPipelineOperators and return a structured result.
+ * Never throws — all errors are captured in the error field.
+ *
+ * @param {string} commandText
+ * @returns {{ segments: string[], error: string | null }}
+ */
+function testSplitOnPipelineOperators(commandText) {
+ try {
+ const segments = splitOnPipelineOperators(commandText);
+ return { segments, error: null };
+ } catch (err) {
+ return { segments: [], error: err instanceof Error ? err.message : String(err) };
+ }
+}
+
+/**
+ * Test extractCommandName and return a structured result.
+ * Never throws.
+ *
+ * @param {string} segment
+ * @returns {{ name: string | null, error: string | null }}
+ */
+function testExtractCommandName(segment) {
+ try {
+ const name = extractCommandName(segment);
+ return { name, error: null };
+ } catch (err) {
+ return { name: null, error: err instanceof Error ? err.message : String(err) };
+ }
+}
+
+/**
+ * Test extractCommandNamesFromPipeline and return a structured result.
+ * Never throws.
+ *
+ * @param {string} commandText
+ * @returns {{ names: string[], error: string | null }}
+ */
+function testExtractCommandNamesFromPipeline(commandText) {
+ try {
+ const names = extractCommandNamesFromPipeline(commandText);
+ return { names, error: null };
+ } catch (err) {
+ return { names: [], error: err instanceof Error ? err.message : String(err) };
+ }
+}
+
+/**
+ * Check the security invariant: a command containing only quoted pipeline operators
+ * must NOT be split into multiple segments.
+ *
+ * @param {string} operator - e.g. "&&", "||", "|", ";"
+ * @returns {boolean} true when the invariant holds
+ */
+function quotedOperatorIsNotSplit(operator) {
+ const singleQuoted = `echo '${operator}'`;
+ const doubleQuoted = `echo "${operator}"`;
+
+ const singleResult = testSplitOnPipelineOperators(singleQuoted);
+ const doubleResult = testSplitOnPipelineOperators(doubleQuoted);
+
+ return (
+ singleResult.error === null &&
+ singleResult.segments.length === 1 &&
+ doubleResult.error === null &&
+ doubleResult.segments.length === 1
+ );
+}
+
+/**
+ * Check the no-throw invariant for a given input.
+ * Returns true when no error is thrown and result arrays are valid.
+ *
+ * @param {string} input
+ * @returns {boolean}
+ */
+function noThrowInvariant(input) {
+ const split = testSplitOnPipelineOperators(input);
+ const name = testExtractCommandName(input);
+ const names = testExtractCommandNamesFromPipeline(input);
+
+ return (
+ split.error === null &&
+ Array.isArray(split.segments) &&
+ name.error === null &&
+ names.error === null &&
+ Array.isArray(names.names)
+ );
+}
+
+/**
+ * Check the safe-default invariant: empty / whitespace-only input yields empty arrays.
+ *
+ * @param {string} input - Should be empty or whitespace-only
+ * @returns {boolean}
+ */
+function emptyInputYieldsEmptyArrays(input) {
+ const split = testSplitOnPipelineOperators(input);
+ const names = testExtractCommandNamesFromPipeline(input);
+ return split.segments.length === 0 && names.names.length === 0;
+}
+
+// ─────────────────────────────────────────────────────────────────────────────
+// Standalone entry point for Go-driven fuzzing
+// ─────────────────────────────────────────────────────────────────────────────
+
+if (require.main === module) {
+ let input = "";
+
+ process.stdin.on("data", chunk => {
+ input += chunk;
+ });
+
+ process.stdin.on("end", () => {
+ try {
+ // Expected JSON: { commandText: string, mode?: "split" | "name" | "pipeline" }
+ const { commandText, mode } = JSON.parse(input);
+ const text = commandText ?? "";
+
+ let result;
+ switch (mode) {
+ case "split":
+ result = testSplitOnPipelineOperators(text);
+ break;
+ case "name":
+ result = testExtractCommandName(text);
+ break;
+ default:
+ result = testExtractCommandNamesFromPipeline(text);
+ }
+
+ process.stdout.write(JSON.stringify(result));
+ process.exit(0);
+ } catch (err) {
+ const errorMsg = err instanceof Error ? err.message : String(err);
+ process.stdout.write(JSON.stringify({ error: errorMsg }));
+ process.exit(1);
+ }
+ });
+}
+
+module.exports = {
+ testSplitOnPipelineOperators,
+ testExtractCommandName,
+ testExtractCommandNamesFromPipeline,
+ quotedOperatorIsNotSplit,
+ noThrowInvariant,
+ emptyInputYieldsEmptyArrays,
+};
diff --git a/setup/js/generate_git_bundle.cjs b/setup/js/generate_git_bundle.cjs
index 2e6ad7f9..b42c03b9 100644
--- a/setup/js/generate_git_bundle.cjs
+++ b/setup/js/generate_git_bundle.cjs
@@ -10,7 +10,7 @@ const fs = require("fs");
const path = require("path");
const { getErrorMessage } = require("./error_helpers.cjs");
-const { execGitSync, getGitAuthEnv } = require("./git_helpers.cjs");
+const { ensureOriginRemoteTrackingRef, execGitSync } = require("./git_helpers.cjs");
const { ERR_SYSTEM } = require("./error_codes.cjs");
/**
@@ -24,41 +24,6 @@ function debugLog(message) {
}
}
-/**
- * Ensure refs/remotes/origin/ is available locally.
- * Returns whether the ref exists and whether a fetch was required.
- *
- * @param {string} branch - Branch name (without origin/ prefix)
- * @param {Object} options
- * @param {string} options.cwd - Working directory for git commands
- * @param {string} [options.token] - Optional auth token used for fetch
- * @param {boolean} [options.suppressLogs=false] - Whether to suppress execGitSync error logs
- * @returns {{ exists: boolean, fetched: boolean, fetchError?: Error }}
- * fetchError is populated only when exists=false after a failed fetch attempt.
- */
-function ensureOriginRemoteTrackingRef(branch, options) {
- const ref = `refs/remotes/origin/${branch}`;
- try {
- execGitSync(["show-ref", "--verify", "--quiet", ref], {
- cwd: options.cwd,
- suppressLogs: options.suppressLogs || false,
- });
- return { exists: true, fetched: false };
- } catch {
- try {
- const fetchEnv = { ...process.env, ...getGitAuthEnv(options.token) };
- execGitSync(["fetch", "origin", "--", branch], {
- cwd: options.cwd,
- env: fetchEnv,
- suppressLogs: options.suppressLogs || false,
- });
- return { exists: true, fetched: true };
- } catch (fetchError) {
- return { exists: false, fetched: false, fetchError };
- }
- }
-}
-
/**
* Sanitize a string for use as a bundle filename component.
* Replaces path separators and special characters with dashes.
@@ -89,7 +54,7 @@ function sanitizeBranchNameForBundle(branchName) {
* @param {string} branchName - The branch name
* @returns {string} The full bundle file path
*/
-function getBundlePath(branchName) {
+function getBundlePathForBranch(branchName) {
const sanitized = sanitizeBranchNameForBundle(branchName);
return `/tmp/gh-aw/aw-${sanitized}.bundle`;
}
@@ -110,7 +75,7 @@ function sanitizeRepoSlugForBundle(repoSlug) {
* @param {string} repoSlug - The repository slug (owner/repo)
* @returns {string} The full bundle file path including repo disambiguation
*/
-function getBundlePathForRepo(branchName, repoSlug) {
+function getBundlePathForBranchInRepo(branchName, repoSlug) {
const sanitizedBranch = sanitizeBranchNameForBundle(branchName);
const sanitizedRepo = sanitizeRepoSlugForBundle(repoSlug);
return `/tmp/gh-aw/aw-${sanitizedRepo}-${sanitizedBranch}.bundle`;
@@ -141,7 +106,7 @@ async function generateGitBundle(branchName, baseBranch, options = {}) {
// Support custom cwd for multi-repo scenarios
const cwd = options.cwd || process.env.GITHUB_WORKSPACE || process.cwd();
- const bundlePath = options.repoSlug ? getBundlePathForRepo(branchName, options.repoSlug) : getBundlePath(branchName);
+ const bundlePath = options.repoSlug ? getBundlePathForBranchInRepo(branchName, options.repoSlug) : getBundlePathForBranch(branchName);
// Validate baseBranch early to avoid confusing git errors (e.g., origin/undefined)
if (typeof baseBranch !== "string" || baseBranch.trim() === "") {
@@ -184,28 +149,30 @@ async function generateGitBundle(branchName, baseBranch, options = {}) {
if (mode === "incremental") {
// INCREMENTAL MODE (for push_to_pull_request_branch):
// Only include commits that are new since origin/branchName.
- debugLog(`Strategy 1 (incremental): Fetching origin/${branchName}`);
- const fetchEnv = { ...process.env, ...getGitAuthEnv(options.token) };
-
- try {
- execGitSync(["fetch", "origin", "--", `${branchName}:refs/remotes/origin/${branchName}`], { cwd, env: fetchEnv });
+ // Tries a local-only check first, then a single network fetch attempt.
+ // The fetch will succeed for public repos (no credentials needed) and
+ // fail fast for private repos without credentials (execGitSync runs
+ // git with GIT_TERMINAL_PROMPT=0 and a 60s timeout).
+ debugLog(`Strategy 1 (incremental): Resolving origin/${branchName}`);
+ const incrementalRefResult = ensureOriginRemoteTrackingRef(branchName, { cwd, token: options.token, suppressLogs: true });
+ if (incrementalRefResult.exists) {
baseRef = `origin/${branchName}`;
- debugLog(`Strategy 1 (incremental): Successfully fetched, baseRef=${baseRef}`);
- } catch (fetchError) {
- debugLog(`Strategy 1 (incremental): Fetch failed - ${getErrorMessage(fetchError)}, checking for existing remote tracking ref`);
- try {
- execGitSync(["show-ref", "--verify", "--quiet", `refs/remotes/origin/${branchName}`], { cwd });
- baseRef = `origin/${branchName}`;
- debugLog(`Strategy 1 (incremental): Using existing remote tracking ref as fallback, baseRef=${baseRef}`);
- } catch (refCheckError) {
- debugLog(`Strategy 1 (incremental): No existing remote tracking ref found (${getErrorMessage(refCheckError)}), failing`);
- errorMessage = `Cannot generate incremental bundle: failed to fetch origin/${branchName} and no existing remote tracking ref found. Fetch error: ${getErrorMessage(fetchError)}`;
- return {
- success: false,
- error: errorMessage,
- bundlePath,
- };
+ if (incrementalRefResult.fetched) {
+ debugLog(`Strategy 1 (incremental): Fetched origin/${branchName} from remote, baseRef=${baseRef}`);
+ } else {
+ debugLog(`Strategy 1 (incremental): Using existing remote tracking ref, baseRef=${baseRef}`);
}
+ } else {
+ debugLog(`Strategy 1 (incremental): origin/${branchName} not present locally and remote fetch failed (${incrementalRefResult.fetchError ? getErrorMessage(incrementalRefResult.fetchError) : "no error"}), failing`);
+ errorMessage =
+ `Cannot generate incremental bundle: refs/remotes/origin/${branchName} is not present in checkout '${cwd}' and could not be fetched ` +
+ `(the safe-outputs MCP server has no credentials for private repositories). ` +
+ `Add ${JSON.stringify(branchName)} to the workflow's checkout.fetch list so the branch is fetched during setup.`;
+ return {
+ success: false,
+ error: errorMessage,
+ bundlePath,
+ };
}
} else {
// FULL MODE (for create_pull_request):
@@ -217,17 +184,18 @@ async function generateGitBundle(branchName, baseBranch, options = {}) {
debugLog(`Strategy 1 (full): Using existing origin/${branchName} as baseRef`);
} catch {
debugLog(`Strategy 1 (full): origin/${branchName} not found, trying merge-base with ${defaultBranch}`);
- const defaultBranchRefResult = ensureOriginRemoteTrackingRef(defaultBranch, { cwd, token: options.token });
+ const defaultBranchRefResult = ensureOriginRemoteTrackingRef(defaultBranch, { cwd, token: options.token, suppressLogs: true });
const hasLocalDefaultBranch = defaultBranchRefResult.exists;
if (hasLocalDefaultBranch) {
if (defaultBranchRefResult.fetched) {
- debugLog(`Strategy 1 (full): Successfully fetched origin/${defaultBranch}`);
+ debugLog(`Strategy 1 (full): fetched origin/${defaultBranch} from remote`);
} else {
debugLog(`Strategy 1 (full): origin/${defaultBranch} exists locally`);
}
} else {
- debugLog(`Strategy 1 (full): origin/${defaultBranch} not found locally, attempting fetch`);
- debugLog(`Strategy 1 (full): Fetch failed - ${getErrorMessage(defaultBranchRefResult.fetchError || new Error("Unknown fetch error"))} (will try other strategies)`);
+ debugLog(
+ `Strategy 1 (full): origin/${defaultBranch} not present locally and remote fetch failed (likely private repo without credentials in MCP server). Add ${JSON.stringify(defaultBranch)} to checkout.fetch to enable this strategy. Falling through to Strategy 2.`
+ );
}
if (hasLocalDefaultBranch) {
@@ -262,13 +230,10 @@ async function generateGitBundle(branchName, baseBranch, options = {}) {
suppressLogs: true,
});
if (defaultBranchRefResult.exists) {
- if (defaultBranchRefResult.fetched) {
- debugLog(`Strategy 1 (incremental): fetched origin/${defaultBranch} for bundle exclusions`);
- }
bundleCreateArgs.push(`^origin/${defaultBranch}`);
debugLog(`Strategy 1 (incremental): excluding origin/${defaultBranch} from bundle prerequisites`);
} else {
- const warningMessage = `Strategy 1 (incremental): could not fetch origin/${defaultBranch} for exclusions - ${getErrorMessage(defaultBranchRefResult.fetchError || new Error("Unknown fetch error"))}. Bundle will include base-branch history.`;
+ const warningMessage = `Strategy 1 (incremental): origin/${defaultBranch} not present locally and remote fetch failed (likely private repo without credentials in MCP server); bundle will include base-branch history. Add ${JSON.stringify(defaultBranch)} to checkout.fetch to enable this optimisation.`;
debugLog(warningMessage);
core.warning(warningMessage);
}
@@ -466,8 +431,8 @@ async function generateGitBundle(branchName, baseBranch, options = {}) {
module.exports = {
generateGitBundle,
- getBundlePath,
- getBundlePathForRepo,
+ getBundlePathForBranch,
+ getBundlePathForBranchInRepo,
sanitizeBranchNameForBundle,
sanitizeRepoSlugForBundle,
};
diff --git a/setup/js/generate_git_patch.cjs b/setup/js/generate_git_patch.cjs
index a98eb421..f3458105 100644
--- a/setup/js/generate_git_patch.cjs
+++ b/setup/js/generate_git_patch.cjs
@@ -10,9 +10,9 @@ const fs = require("fs");
const path = require("path");
const { getErrorMessage } = require("./error_helpers.cjs");
-const { execGitSync, getGitAuthEnv } = require("./git_helpers.cjs");
+const { ensureOriginRemoteTrackingRef, execGitSync } = require("./git_helpers.cjs");
const { ERR_SYSTEM } = require("./error_codes.cjs");
-const { sanitizeForFilename, sanitizeBranchNameForPatch, sanitizeRepoSlugForPatch, getPatchPath, getPatchPathForRepo, buildExcludePathspecs, computeIncrementalDiffSize } = require("./git_patch_utils.cjs");
+const { sanitizeForFilename, sanitizeBranchNameForPatch, sanitizeRepoSlugForPatch, getPatchPathForBranch, getPatchPathForBranchInRepo, buildExcludePathspecs, computeIncrementalDiffSize } = require("./git_patch_utils.cjs");
// sanitizeForFilename is re-exported below for backward compatibility with
// existing callers that imported it from this module.
@@ -73,7 +73,7 @@ async function generateGitPatch(branchName, baseBranch, options = {}) {
function excludeArgs() {
return excludeArgsArr;
}
- const patchPath = options.repoSlug ? getPatchPathForRepo(branchName, options.repoSlug) : getPatchPath(branchName);
+ const patchPath = options.repoSlug ? getPatchPathForBranchInRepo(branchName, options.repoSlug) : getPatchPathForBranch(branchName);
if (options.workspacePath !== undefined && options.workspacePath !== null && String(options.workspacePath).trim() !== "") {
const root = path.resolve(workspaceRoot);
@@ -159,50 +159,31 @@ async function generateGitPatch(branchName, baseBranch, options = {}) {
if (mode === "incremental") {
// INCREMENTAL MODE (for push_to_pull_request_branch):
// Only include commits that are new since origin/branchName.
- // This prevents including commits that already exist on the PR branch.
- // Prefer a fresh fetch of origin/branchName; fall back to the existing
- // remote tracking ref (set up by the initial shallow checkout) when the
- // fetch fails (e.g. due to shallow clone limitations or missing credentials).
-
- debugLog(`Strategy 1 (incremental): Fetching origin/${branchName}`);
- // Configure git authentication via GIT_CONFIG_* environment variables.
- // This ensures the fetch works when .git/config credentials are unavailable
- // (e.g. after clean_git_credentials.sh) and on GitHub Enterprise Server (GHES).
- // Use options.token when provided (cross-repo PAT), falling back to GITHUB_TOKEN.
- // SECURITY: The auth header is passed via env vars so it is never written to
- // .git/config on disk, preventing file-monitoring attacks.
- const fetchEnv = { ...process.env, ...getGitAuthEnv(options.token) };
-
- try {
- // Explicitly fetch origin/branchName to ensure we have the latest
- // Use "--" to prevent branch names starting with "-" from being interpreted as options
- execGitSync(["fetch", "origin", "--", `${branchName}:refs/remotes/origin/${branchName}`], { cwd, env: fetchEnv });
+ // Tries a local-only check first, then a single network fetch attempt.
+ // The fetch will succeed for public repos (no credentials needed) and
+ // fail fast for private repos without credentials (execGitSync runs
+ // git with GIT_TERMINAL_PROMPT=0 and a 60s timeout).
+
+ debugLog(`Strategy 1 (incremental): Resolving origin/${branchName}`);
+ const incrementalRefResult = ensureOriginRemoteTrackingRef(branchName, { cwd, token: options.token, suppressLogs: true });
+ if (incrementalRefResult.exists) {
baseRef = `origin/${branchName}`;
- debugLog(`Strategy 1 (incremental): Successfully fetched, baseRef=${baseRef}`);
- } catch (fetchError) {
- // Fetch failed. Check if origin/branchName already exists from the initial shallow checkout.
- // This handles cases where git fetch fails due to shallow clone limitations or when
- // GITHUB_TOKEN is unavailable in the MCP server process (e.g. after clean_git_credentials.sh).
- // Using the existing remote tracking ref as a fallback is safe: it represents the state
- // of the branch at checkout time, so the incremental patch will include all commits
- // made by the agent since then.
- debugLog(`Strategy 1 (incremental): Fetch failed - ${getErrorMessage(fetchError)}, checking for existing remote tracking ref`);
- try {
- execGitSync(["show-ref", "--verify", "--quiet", `refs/remotes/origin/${branchName}`], { cwd });
- // Remote tracking ref exists from initial shallow checkout — use it as base
- baseRef = `origin/${branchName}`;
- debugLog(`Strategy 1 (incremental): Using existing remote tracking ref as fallback, baseRef=${baseRef}`);
- } catch (refCheckError) {
- // No remote tracking ref at all — cannot safely generate an incremental patch.
- // Report both errors: the original fetch failure and the missing ref.
- debugLog(`Strategy 1 (incremental): No existing remote tracking ref found (${getErrorMessage(refCheckError)}), failing`);
- errorMessage = `Cannot generate incremental patch: failed to fetch origin/${branchName} and no existing remote tracking ref found. This typically happens when the remote branch doesn't exist yet or was force-pushed. Fetch error: ${getErrorMessage(fetchError)}`;
- return {
- success: false,
- error: errorMessage,
- patchPath: patchPath,
- };
+ if (incrementalRefResult.fetched) {
+ debugLog(`Strategy 1 (incremental): Fetched origin/${branchName} from remote, baseRef=${baseRef}`);
+ } else {
+ debugLog(`Strategy 1 (incremental): Using existing remote tracking ref, baseRef=${baseRef}`);
}
+ } else {
+ debugLog(`Strategy 1 (incremental): origin/${branchName} not present locally and remote fetch failed (${incrementalRefResult.fetchError ? getErrorMessage(incrementalRefResult.fetchError) : "no error"}), failing`);
+ errorMessage =
+ `Cannot generate incremental patch: refs/remotes/origin/${branchName} is not present in checkout '${cwd}' and could not be fetched ` +
+ `(the safe-outputs MCP server has no credentials for private repositories). ` +
+ `Add ${JSON.stringify(branchName)} to the workflow's checkout.fetch list so the branch is fetched during setup.`;
+ return {
+ success: false,
+ error: errorMessage,
+ patchPath: patchPath,
+ };
}
} else {
// FULL MODE (for create_pull_request):
@@ -219,33 +200,21 @@ async function generateGitPatch(branchName, baseBranch, options = {}) {
// never made. Always compute the merge-base with the default branch so the patch
// contains exactly the agent's changes.
debugLog(`Strategy 1 (full): Computing merge-base with ${defaultBranch} (ignoring any stale origin/${branchName})`);
- // Check if origin/ already exists locally (e.g., from checkout with fetch-depth: 0)
- // This is important for cross-repo checkouts where persist-credentials: false prevents fetching
- let hasLocalDefaultBranch = false;
- try {
- execGitSync(["show-ref", "--verify", "--quiet", `refs/remotes/origin/${defaultBranch}`], { cwd });
- hasLocalDefaultBranch = true;
- debugLog(`Strategy 1 (full): origin/${defaultBranch} exists locally`);
- } catch {
- // origin/ doesn't exist locally, try to fetch it
- debugLog(`Strategy 1 (full): origin/${defaultBranch} not found locally, attempting fetch`);
- try {
- // Configure git authentication via GIT_CONFIG_* environment variables.
- // This ensures the fetch works when .git/config credentials are unavailable
- // (e.g. after clean_git_credentials.sh) and on GitHub Enterprise Server (GHES).
- // Use options.token when provided (cross-repo PAT), falling back to GITHUB_TOKEN.
- // SECURITY: The auth header is passed via env vars so it is never written to
- // .git/config on disk, preventing file-monitoring attacks.
- const fullFetchEnv = { ...process.env, ...getGitAuthEnv(options.token) };
- // Use "--" to prevent branch names starting with "-" from being interpreted as options
- execGitSync(["fetch", "origin", "--", defaultBranch], { cwd, env: fullFetchEnv });
- hasLocalDefaultBranch = true;
- debugLog(`Strategy 1 (full): Successfully fetched origin/${defaultBranch}`);
- } catch (fetchErr) {
- // Fetch failed (likely due to persist-credentials: false in cross-repo checkout)
- // We'll try other strategies below
- debugLog(`Strategy 1 (full): Fetch failed - ${getErrorMessage(fetchErr)} (will try other strategies)`);
+ // Check if origin/ already exists locally (e.g., from checkout with fetch-depth: 0).
+ // When missing, try a single network fetch — succeeds for public repos and
+ // fails fast for private repos without credentials.
+ const defaultBranchRefResult = ensureOriginRemoteTrackingRef(defaultBranch, { cwd, token: options.token, suppressLogs: true });
+ const hasLocalDefaultBranch = defaultBranchRefResult.exists;
+ if (hasLocalDefaultBranch) {
+ if (defaultBranchRefResult.fetched) {
+ debugLog(`Strategy 1 (full): fetched origin/${defaultBranch} from remote`);
+ } else {
+ debugLog(`Strategy 1 (full): origin/${defaultBranch} exists locally`);
}
+ } else {
+ debugLog(
+ `Strategy 1 (full): origin/${defaultBranch} not present locally and remote fetch failed (likely private repo without credentials in MCP server). Add ${JSON.stringify(defaultBranch)} to checkout.fetch to enable this strategy.`
+ );
}
// If origin/ is unavailable (e.g. credentials were cleaned),
@@ -616,8 +585,8 @@ async function generateGitPatch(branchName, baseBranch, options = {}) {
module.exports = {
generateGitPatch,
- getPatchPath,
- getPatchPathForRepo,
+ getPatchPathForBranch,
+ getPatchPathForBranchInRepo,
sanitizeBranchNameForPatch,
sanitizeRepoSlugForPatch,
};
diff --git a/setup/js/git_helpers.cjs b/setup/js/git_helpers.cjs
index d803db2c..f81f2a92 100644
--- a/setup/js/git_helpers.cjs
+++ b/setup/js/git_helpers.cjs
@@ -38,7 +38,12 @@ function getGitAuthEnv(token) {
}
/**
- * Safely execute git command using spawnSync with args array to prevent shell injection
+ * Safely execute git command using spawnSync with args array to prevent shell injection.
+ *
+ * Hardened against indefinite hangs: always runs git with non-interactive
+ * credential settings (GIT_TERMINAL_PROMPT=0, GCM_INTERACTIVE=Never,
+ * GIT_ASKPASS=/bin/echo) and a default 60s timeout (override via options.timeout).
+ *
* @param {string[]} args - Git command arguments
* @param {Object} options - Spawn options; set suppressLogs: true to avoid core.error annotations for expected failures
* @returns {string} Command output
@@ -65,10 +70,27 @@ function execGitSync(args, options = {}) {
core.debug(`Executing git command: ${gitCommand}`);
+ // Hard guards against indefinite hangs:
+ // - GIT_TERMINAL_PROMPT=0 / GCM_INTERACTIVE=Never / GIT_ASKPASS make any
+ // credential rejection fail fast instead of opening an interactive prompt.
+ // - timeout (default 60s) ensures a stuck network/TLS handshake cannot
+ // wedge the calling event loop. Callers can override via options.timeout.
+ const callerEnv = spawnOptions.env || process.env;
+ const safeEnv = {
+ ...callerEnv,
+ GIT_TERMINAL_PROMPT: "0",
+ GCM_INTERACTIVE: "Never",
+ GIT_ASKPASS: "/bin/echo",
+ };
+ const defaultTimeoutMs = 60_000;
+
const result = spawnSync("git", args, {
encoding: "utf8",
maxBuffer: 100 * 1024 * 1024, // 100 MB — prevents ENOBUFS on large diffs (e.g. git format-patch)
+ timeout: defaultTimeoutMs,
+ killSignal: "SIGKILL",
...spawnOptions,
+ env: safeEnv,
});
if (result.error) {
@@ -82,12 +104,28 @@ function execGitSync(args, options = {}) {
core.error(`Git command buffer overflow: ${gitCommand}`);
throw bufferError;
}
+ if (spawnError.code === "ETIMEDOUT") {
+ /** @type {NodeJS.ErrnoException} */
+ const timeoutError = new Error(`${ERR_SYSTEM}: Git command timed out after ${spawnOptions.timeout || defaultTimeoutMs}ms: ${gitCommand}`);
+ timeoutError.code = "ETIMEDOUT";
+ core.error(`Git command timed out: ${gitCommand}`);
+ throw timeoutError;
+ }
// Spawn-level errors (e.g. ENOENT, EACCES) are always unexpected — log
// via core.error regardless of suppressLogs.
core.error(`Git command failed with error: ${result.error.message}`);
throw result.error;
}
+ // spawnSync sets signal when the process was killed (including by the timeout).
+ if (result.signal === "SIGKILL" || result.signal === "SIGTERM") {
+ /** @type {NodeJS.ErrnoException} */
+ const timeoutError = new Error(`${ERR_SYSTEM}: Git command killed (${result.signal}), likely due to timeout (${spawnOptions.timeout || defaultTimeoutMs}ms): ${gitCommand}`);
+ timeoutError.code = "ETIMEDOUT";
+ core.error(`Git command killed by signal ${result.signal}: ${gitCommand}`);
+ throw timeoutError;
+ }
+
if (result.status !== 0) {
const errorMsg = `${ERR_SYSTEM}: ${result.stderr || `Git command failed with status ${result.status}`}`;
if (suppressLogs) {
@@ -115,6 +153,49 @@ function execGitSync(args, options = {}) {
return result.stdout;
}
+/**
+ * Ensure refs/remotes/origin/ is available locally, attempting a
+ * single fetch when it is not. Returns whether the ref now exists and
+ * whether a fetch was required.
+ *
+ * Safe to call from the credential-less safe-outputs MCP server: execGitSync
+ * runs git with GIT_TERMINAL_PROMPT=0 / GIT_ASKPASS=/bin/echo and a 60s
+ * timeout, so the fetch attempt either succeeds (public repos, or when a
+ * token was provided) or fails fast (private repos without credentials).
+ * Callers MUST treat exists=false as a recoverable negative result rather
+ * than an error condition.
+ *
+ * @param {string} branch - Branch name (without origin/ prefix)
+ * @param {Object} options
+ * @param {string} options.cwd - Working directory for git commands
+ * @param {string} [options.token] - Optional auth token used for fetch
+ * @param {boolean} [options.suppressLogs=false] - Whether to suppress execGitSync error logs
+ * @returns {{ exists: boolean, fetched: boolean, fetchError?: Error }}
+ * fetchError is populated only when exists=false after a failed fetch attempt.
+ */
+function ensureOriginRemoteTrackingRef(branch, options) {
+ const ref = `refs/remotes/origin/${branch}`;
+ try {
+ execGitSync(["show-ref", "--verify", "--quiet", ref], {
+ cwd: options.cwd,
+ suppressLogs: options.suppressLogs || false,
+ });
+ return { exists: true, fetched: false };
+ } catch {
+ try {
+ const fetchEnv = { ...process.env, ...getGitAuthEnv(options.token) };
+ execGitSync(["fetch", "origin", "--", `${branch}:refs/remotes/origin/${branch}`], {
+ cwd: options.cwd,
+ env: fetchEnv,
+ suppressLogs: options.suppressLogs || false,
+ });
+ return { exists: true, fetched: true };
+ } catch (fetchError) {
+ return { exists: false, fetched: false, fetchError: /** @type {Error} */ fetchError };
+ }
+ }
+}
+
/**
* Check whether a commit range contains any merge commits.
*
@@ -152,21 +233,108 @@ function hasMergeCommitsInRange(baseRef, headRef, options = {}) {
}
/**
- * Probe shallow-repository status before fetching a git bundle.
+ * Deepen sequence (per call to `git fetch --deepen=N`). Each value adds N
+ * commits to the existing shallow history. Total reachable depth after the
+ * final step is the sum of these values (~7850 commits).
+ */
+const BUNDLE_DEEPEN_STEPS = [50, 100, 200, 500, 1000, 2000, 4000];
+
+/**
+ * Extract prerequisite commit SHAs declared in a git bundle file.
+ *
+ * Runs `git bundle verify ` (with `ignoreReturnCode`) and parses the
+ * "The bundle requires this ref:" section as well as the
+ * "Repository lacks these prerequisite commits:" error block. Both formats
+ * list the prerequisite commit SHAs.
+ *
+ * @param {{ getExecOutput: Function }} execApi
+ * @param {string} bundleFilePath
+ * @param {Object} [options]
+ * @returns {Promise} Deduplicated lowercase 40-char SHAs, or [] on failure.
+ */
+async function getBundlePrerequisites(execApi, bundleFilePath, options = {}) {
+ try {
+ const { stdout, stderr } = await execApi.getExecOutput("git", ["bundle", "verify", bundleFilePath], { ...options, ignoreReturnCode: true, silent: true });
+ const combined = `${stdout || ""}\n${stderr || ""}`;
+ const prereqs = new Set();
+ const lines = combined.split(/\r?\n/);
+ let inRequires = false;
+ for (const line of lines) {
+ if (/the bundle (requires|records) (this|these)/i.test(line)) {
+ inRequires = true;
+ continue;
+ }
+ if (/the bundle contains/i.test(line)) {
+ inRequires = false;
+ continue;
+ }
+ if (inRequires) {
+ const match = line.match(/\b([0-9a-f]{40})\b/i);
+ if (match) {
+ prereqs.add(match[1].toLowerCase());
+ continue;
+ }
+ if (line.trim() === "") {
+ inRequires = false;
+ }
+ }
+ }
+ // Also pick up "Repository lacks these prerequisite commits:" block.
+ for (const sha of extractBundlePrerequisiteCommits(combined)) {
+ prereqs.add(sha);
+ }
+ return [...prereqs];
+ } catch (error) {
+ core.debug(`getBundlePrerequisites failed: ${getErrorMessage(error)}`);
+ return [];
+ }
+}
+
+/**
+ * Check which of the given SHAs are NOT yet ancestors of `targetRef`.
+ *
+ * @param {{ getExecOutput: Function }} execApi
+ * @param {string[]} shas
+ * @param {string} targetRef
+ * @param {Object} [options]
+ * @returns {Promise} SHAs still missing (not ancestors / not present).
+ */
+async function findMissingAncestors(execApi, shas, targetRef, options = {}) {
+ const missing = [];
+ for (const sha of shas) {
+ const { exitCode } = await execApi.getExecOutput("git", ["merge-base", "--is-ancestor", sha, targetRef], { ...options, ignoreReturnCode: true, silent: true });
+ if (exitCode !== 0) {
+ missing.push(sha);
+ }
+ }
+ return missing;
+}
+
+/**
+ * Probe shallow-repository status before fetching a git bundle, and deepen
+ * the local clone as needed so the bundle's prerequisite commits become
+ * reachable from `origin/`.
*
* Bundles generated from a commit range can declare prerequisite commits. A
- * depth-1 checkout may not contain those prerequisites, and `git fetch `
- * can reject the bundle before the caller can update refs.
+ * shallow checkout (e.g. `fetch-depth: 20`) may not contain those prerequisites,
+ * and `git fetch ` will reject the bundle before the caller can update
+ * refs. On a high-churn monorepo, `git fetch --unshallow` is catastrophic — it
+ * downloads the entire history. Instead we iterate `git fetch origin
+ * --deepen=` with progressively larger N until every declared prerequisite
+ * satisfies `git merge-base --is-ancestor origin/`.
*
- * IMPORTANT: Do not unshallow here. Full-history fetches are prohibitively
- * expensive for large monorepos. Callers recover from prerequisite failures by
- * fetching only the missing commit objects from origin and retrying.
+ * When `deepenOptions.baseRef` or `deepenOptions.bundleFilePath` is missing
+ * (legacy callers), the function falls back to the previous behavior of a
+ * single `git fetch --unshallow origin`.
*
* @param {{ getExecOutput: Function, exec: Function }} execApi - Exec API to run git commands.
* @param {Object} [options] - Options passed through to exec calls.
+ * @param {Object} [deepenOptions]
+ * @param {string} [deepenOptions.baseRef] - Remote branch name to deepen (no `origin/` prefix).
+ * @param {string} [deepenOptions.bundleFilePath] - Path to the bundle file whose prerequisites must become reachable.
* @returns {Promise}
*/
-async function ensureFullHistoryForBundle(execApi, options = {}) {
+async function ensureFullHistoryForBundle(execApi, options = {}, deepenOptions = {}) {
let stdout;
try {
({ stdout } = await execApi.getExecOutput("git", ["rev-parse", "--is-shallow-repository"], options));
@@ -175,8 +343,56 @@ async function ensureFullHistoryForBundle(execApi, options = {}) {
core.warning(`Could not determine shallow repository status; skipping full-history fetch probe: ${message}`);
return;
}
- if (stdout.trim() === "true") {
- core.info("Repository is shallow; skipping full-history fetch and relying on prerequisite recovery");
+ if (stdout.trim() !== "true") {
+ return;
+ }
+
+ const { baseRef, bundleFilePath } = deepenOptions || {};
+
+ // Legacy path: no base ref / bundle info known — fall back to a single
+ // unshallow. Callers in monorepos should always supply baseRef + bundleFilePath
+ // to get incremental deepening instead.
+ if (!baseRef || !bundleFilePath) {
+ core.info("Repository is shallow; fetching full history before bundle processing (no baseRef/bundle info; using --unshallow)");
+ await execApi.exec("git", ["fetch", "--unshallow", "origin"], options);
+ return;
+ }
+
+ const prereqs = await getBundlePrerequisites(execApi, bundleFilePath, options);
+ if (prereqs.length === 0) {
+ core.info("Bundle declares no prerequisites; no deepen required");
+ return;
+ }
+
+ const targetRef = `origin/${baseRef}`;
+ const alreadyMissing = await findMissingAncestors(execApi, prereqs, targetRef, options);
+ if (alreadyMissing.length === 0) {
+ core.info(`Bundle prerequisites already reachable from ${targetRef}; no deepen required`);
+ return;
+ }
+
+ core.info(`Repository is shallow; iteratively deepening ${targetRef} to satisfy ${alreadyMissing.length} bundle prerequisite commit(s)`);
+ let missing = alreadyMissing;
+ for (const depth of BUNDLE_DEEPEN_STEPS) {
+ core.info(`Fetching origin ${baseRef} with --deepen=${depth} (${missing.length} prerequisite(s) still missing)`);
+ try {
+ await execApi.exec("git", ["fetch", `--deepen=${depth}`, "origin", baseRef], options);
+ } catch (fetchError) {
+ core.warning(`git fetch --deepen=${depth} origin ${baseRef} failed: ${getErrorMessage(fetchError)}; aborting iterative deepen`);
+ break;
+ }
+ missing = await findMissingAncestors(execApi, prereqs, targetRef, options);
+ if (missing.length === 0) {
+ core.info(`Bundle prerequisites reachable after --deepen=${depth}`);
+ return;
+ }
+ }
+
+ core.warning(`Bundle prerequisites still not reachable after iterative deepen (${missing.length} remaining); attempting --unshallow as a last resort`);
+ try {
+ await execApi.exec("git", ["fetch", "--unshallow", "origin", baseRef], options);
+ } catch (unshallowError) {
+ core.warning(`Fallback --unshallow fetch failed: ${getErrorMessage(unshallowError)}; bundle apply may still fail`);
}
}
@@ -300,6 +516,7 @@ async function linearizeRangeAsCommit(baseRef, commitMessage, execApi, opts = {}
module.exports = {
execGitSync,
ensureFullHistoryForBundle,
+ ensureOriginRemoteTrackingRef,
extractBundlePrerequisiteCommits,
getGitAuthEnv,
hasMergeCommitsInRange,
diff --git a/setup/js/git_patch_utils.cjs b/setup/js/git_patch_utils.cjs
index b007c0fb..162dbe90 100644
--- a/setup/js/git_patch_utils.cjs
+++ b/setup/js/git_patch_utils.cjs
@@ -67,7 +67,7 @@ function sanitizeRepoSlugForPatch(repoSlug) {
* @param {string} branchName - The branch name
* @returns {string} The full patch file path
*/
-function getPatchPath(branchName) {
+function getPatchPathForBranch(branchName) {
const sanitized = sanitizeBranchNameForPatch(branchName);
return `/tmp/gh-aw/aw-${sanitized}.patch`;
}
@@ -79,7 +79,7 @@ function getPatchPath(branchName) {
* @param {string} repoSlug - The repository slug (owner/repo)
* @returns {string} The full patch file path including repo disambiguation
*/
-function getPatchPathForRepo(branchName, repoSlug) {
+function getPatchPathForBranchInRepo(branchName, repoSlug) {
const sanitizedBranch = sanitizeBranchNameForPatch(branchName);
const sanitizedRepo = sanitizeRepoSlugForPatch(repoSlug);
return `/tmp/gh-aw/aw-${sanitizedRepo}-${sanitizedBranch}.patch`;
@@ -155,8 +155,8 @@ module.exports = {
sanitizeForFilename,
sanitizeBranchNameForPatch,
sanitizeRepoSlugForPatch,
- getPatchPath,
- getPatchPathForRepo,
+ getPatchPathForBranch,
+ getPatchPathForBranchInRepo,
buildExcludePathspecs,
computeIncrementalDiffSize,
};
diff --git a/setup/js/handle_agent_failure.cjs b/setup/js/handle_agent_failure.cjs
index 0391d7ef..27ae5883 100644
--- a/setup/js/handle_agent_failure.cjs
+++ b/setup/js/handle_agent_failure.cjs
@@ -11,19 +11,21 @@ const { MAX_SUB_ISSUES, getSubIssueCount } = require("./sub_issue_helpers.cjs");
const { formatMissingData, formatMissingTools } = require("./missing_info_formatter.cjs");
const { generateHistoryUrl } = require("./generate_history_link.cjs");
const { AWF_INFRA_LINE_RE } = require("./log_parser_shared.cjs");
-const { resolveFirewallAuditLogPath, parseMaxEffectiveTokensFromAuditLog, parseEffectiveTokensErrorInfoFromAuditLog, resolveEffectiveTokensFailureState } = require("./effective_tokens_context.cjs");
+const { resolveFirewallAuditLogPath, parseMaxEffectiveTokensFromAuditLog, parseEffectiveTokensErrorInfoFromAuditLog, resolveEffectiveTokensFailureState, resolveAICreditsFailureState } = require("./effective_tokens_context.cjs");
const { formatET, buildETComputationTable } = require("./effective_tokens.cjs");
const { isMaxEffectiveTokensExceededError } = require("./effective_tokens_hard_rail.cjs");
const { parseTokenUsageJsonl, generateTokenUsageSummary } = require("./parse_mcp_gateway_log.cjs");
const { readDedupedTokenUsage, TOKEN_USAGE_PATHS } = require("./parse_token_usage.cjs");
const fs = require("fs");
+const os = require("os");
const path = require("path");
const DEFAULT_ACTION_FAILURE_ISSUE_EXPIRES_HOURS = 24 * 7;
const FAILURE_ISSUE_DEDUP_WINDOW_HOURS = 24;
-const FAILURE_ISSUE_CATEGORY_DAILY_CAP = 5;
+const FAILURE_ISSUE_CATEGORY_DAILY_CAP = 50;
const FAILURE_ISSUE_WINDOW_MS = FAILURE_ISSUE_DEDUP_WINDOW_HOURS * 60 * 60 * 1000;
const DEFAULT_OTEL_JSONL_PATH = "/tmp/gh-aw/otel.jsonl";
+const COPILOT_SESSION_STATE_DIR = path.join(os.tmpdir(), "gh-aw", "sandbox", "agent", "logs", "copilot-session-state");
// Engine-side 429/rate-limit signatures:
// - HTTP 429 accompanied by "too many requests"/"rate limit" phrasing
// - provider error codes like rate_limit_error / rate_limit_exceeded
@@ -162,6 +164,7 @@ function buildFailureMatchCategories(options) {
if (options.hasMissingSafeOutputs) categories.push("missing_safe_outputs");
if (options.hasReportIncomplete) categories.push("report_incomplete");
if (options.hasMissingTool) categories.push("missing_tool");
+ if (options.hasToolDenialsExceeded) categories.push("tool_denials_exceeded");
if (options.hasMissingData) categories.push("missing_data");
if (options.hasCacheMissMisconfiguration) categories.push("cache_miss_misconfiguration");
if (options.secretVerificationFailed) categories.push("secret_verification_failed");
@@ -169,6 +172,7 @@ function buildFailureMatchCategories(options) {
if (options.mcpPolicyError) categories.push("mcp_policy_error");
if (options.modelNotSupportedError) categories.push("model_not_supported_error");
if (options.effectiveTokensRateLimitError) categories.push("effective_tokens_rate_limit_error");
+ if (options.aiCreditsRateLimitError) categories.push("ai_credits_rate_limit_error");
if (options.hasAppTokenMintingFailed) categories.push("app_token_minting_failed");
if (options.hasLockdownCheckFailed) categories.push("lockdown_check_failed");
if (options.hasStaleLockFileFailed) categories.push("stale_lock_file_failed");
@@ -1063,6 +1067,90 @@ function buildPermissionDeniedContext(items, workflowId) {
}
}
+/**
+ * Load max-tool-denials guard events from Copilot SDK session events.jsonl files.
+ * @returns {Array<{denialCount: number, threshold: number, reason: string}>}
+ */
+function loadToolDenialsExceededEvents() {
+ try {
+ if (!fs.existsSync(COPILOT_SESSION_STATE_DIR)) {
+ return [];
+ }
+
+ const events = [];
+ const sessionDirs = fs.readdirSync(COPILOT_SESSION_STATE_DIR, { withFileTypes: true });
+ for (const entry of sessionDirs) {
+ if (!entry.isDirectory()) continue;
+ const eventsPath = path.join(COPILOT_SESSION_STATE_DIR, entry.name, "events.jsonl");
+ if (!fs.existsSync(eventsPath)) continue;
+ const content = fs.readFileSync(eventsPath, "utf8");
+ const lines = content.split("\n");
+ for (const rawLine of lines) {
+ const line = rawLine.trim();
+ if (!line) continue;
+ try {
+ const parsed = JSON.parse(line);
+ if (parsed.type !== "guard.tool_denials_exceeded" || !parsed.data || typeof parsed.data !== "object") {
+ continue;
+ }
+ const denialCount = Number.parseInt(String(parsed.data.denialCount), 10);
+ const threshold = Number.parseInt(String(parsed.data.threshold), 10);
+ if (!Number.isFinite(denialCount) || !Number.isFinite(threshold)) {
+ continue;
+ }
+ events.push({
+ denialCount,
+ threshold,
+ reason: typeof parsed.data.reason === "string" ? parsed.data.reason.trim() : "",
+ });
+ } catch {
+ // Skip malformed lines
+ }
+ }
+ }
+ return events;
+ } catch (error) {
+ core.warning(`Failed to load tool-denials-exceeded events: ${getErrorMessage(error)}`);
+ return [];
+ }
+}
+
+/**
+ * Build context for max-tool-denials guardrail failures from Copilot SDK events.
+ * @param {Array<{denialCount: number, threshold: number, reason: string}>} events
+ * @param {string} [workflowId]
+ * @returns {string}
+ */
+function buildToolDenialsExceededContext(events, workflowId) {
+ if (!Array.isArray(events) || events.length === 0) {
+ return "";
+ }
+ const latestEvent = events[events.length - 1];
+ const denialCount = String(latestEvent.denialCount);
+ const threshold = String(latestEvent.threshold);
+ const reason = latestEvent.reason || "permission denied by workflow tool permissions";
+
+ try {
+ const templatePath = getPromptPath("tool_denials_exceeded_context.md");
+ const template = fs.readFileSync(templatePath, "utf8");
+ return (
+ "\n" +
+ renderTemplate(template, {
+ denial_count: denialCount,
+ threshold,
+ reason: `\`${reason}\``,
+ workflow_id: workflowId || "the workflow",
+ })
+ );
+ } catch {
+ return (
+ `\n**⚠️ Excessive Tool Denials**: The Copilot SDK stopped the session after ${denialCount}/${threshold} permission denials.\n\n` +
+ `**Last denied request:** \`${reason}\`\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"
+ );
+ }
+}
+
/**
* Load report_incomplete messages from agent output
* @param {Array} [items] - Optional pre-loaded agent output items. When provided, avoids re-reading the output file.
@@ -1323,8 +1411,8 @@ function buildEffectiveTokensRateLimitErrorContext(hasEffectiveTokensRateLimitEr
return (
"\n" +
renderTemplateFromFile(templatePath, {
- et_spec_link: "https://github.github.com/gh-aw/specs/effective-tokens-specification/",
- token_opt_link: "https://github.com/github/gh-aw/blob/main/.github/aw/token-optimization.md",
+ ai_credits_spec_link: "https://github.github.com/gh-aw/specs/ai-credits-specification/",
+ cost_management_link: "https://github.github.com/gh-aw/reference/cost-management/",
usage_line: usageLine,
budget_line: budgetLine,
run_line: runLine,
@@ -1332,7 +1420,48 @@ function buildEffectiveTokensRateLimitErrorContext(hasEffectiveTokensRateLimitEr
})
);
} catch (error) {
- throw new Error(`failed to render template at ${templatePath}: ${getErrorMessage(error)}; ` + "verify template syntax and required placeholders: " + "et_spec_link, token_opt_link, usage_line, budget_line, run_line, et_table_section");
+ throw new Error(
+ `failed to render template at ${templatePath}: ${getErrorMessage(error)}; ` + "verify template syntax and required placeholders: " + "ai_credits_spec_link, cost_management_link, usage_line, budget_line, run_line, et_table_section"
+ );
+ }
+}
+
+/**
+ * Build a context string when AI credits budget exhaustion/rate-limit is detected from gateway logs.
+ * @param {boolean} hasAICreditsRateLimitError
+ * @param {string} aiCredits
+ * @param {string} maxAICredits
+ * @param {string} runUrl
+ * @returns {string}
+ */
+function buildAICreditsRateLimitErrorContext(hasAICreditsRateLimitError, aiCredits, maxAICredits, runUrl) {
+ if (!hasAICreditsRateLimitError) {
+ return "";
+ }
+
+ const usageLine = aiCredits ? `\n- AI credits used: \`${aiCredits}\`` : "";
+ const budgetLine = maxAICredits ? `\n- Configured AI credits budget: \`${maxAICredits}\`` : "";
+ const runLine = runUrl ? `\n- Run: [${runUrl}](${runUrl})` : "";
+
+ const templateName = "ai_credits_rate_limit_error.md";
+ let templatePath = "";
+ try {
+ templatePath = getPromptPath(templateName);
+ } catch (error) {
+ throw new Error(`failed to resolve template path for ${templateName} (${getErrorMessage(error)}); ensure RUNNER_TEMP or GH_AW_PROMPTS_DIR is set and the template file exists`);
+ }
+
+ try {
+ return (
+ "\n" +
+ renderTemplateFromFile(templatePath, {
+ usage_line: usageLine,
+ budget_line: budgetLine,
+ run_line: runLine,
+ })
+ );
+ } catch (error) {
+ throw new Error(`failed to render template at ${templatePath}: ${getErrorMessage(error)}; verify template syntax and required placeholders: usage_line, budget_line, run_line`);
}
}
@@ -1825,6 +1954,9 @@ const CASCADE_THRESHOLD = 10;
const CASCADE_ROLLUP_TITLE = "[aw] Failure cascade detected";
const CASCADE_LABEL = "cascade-suspected";
const CASCADE_ROLLUP_LABEL = "cascade-rollup";
+/** Daily-cap rollup constants */
+const DAILY_CAP_ROLLUP_TITLE = "[aw] Daily failure issue cap exceeded";
+const DAILY_CAP_ROLLUP_LABEL = "daily-cap-exceeded";
/** Matches the exact title pattern produced by handle_agent_failure for individual failure issues */
const FAILURE_TITLE_PATTERN = /^\[aw\] .+ failed$/;
@@ -1924,6 +2056,51 @@ async function findExistingCascadeRollupIssue(owner, repo) {
return null;
}
+/**
+ * Find an existing open daily-cap rollup issue, or create one.
+ * @param {string} owner
+ * @param {string} repo
+ * @returns {Promise<{number: number, html_url: string} | null>}
+ */
+async function findOrCreateDailyCapRollupIssue(owner, repo) {
+ const searchQuery = `repo:${owner}/${repo} is:issue is:open label:agentic-workflows in:title "${DAILY_CAP_ROLLUP_TITLE}"`;
+ try {
+ const result = await github.rest.search.issuesAndPullRequests({
+ q: searchQuery,
+ per_page: 1,
+ });
+ if (result.data.total_count > 0) {
+ const item = result.data.items[0];
+ core.info(`Found existing daily cap rollup issue #${item.number}: ${item.html_url}`);
+ return { number: item.number, html_url: item.html_url };
+ }
+ } catch (err) {
+ core.warning(`Could not search for daily cap rollup issue: ${getErrorMessage(err)}`);
+ }
+
+ // No existing issue found — create one
+ const body = renderTemplateFromFile(getPromptPath("daily_cap_rollup_issue.md"), {
+ cap: FAILURE_ISSUE_CATEGORY_DAILY_CAP,
+ window_hours: FAILURE_ISSUE_DEDUP_WINDOW_HOURS,
+ });
+
+ try {
+ await ensureLabelExists(owner, repo, DAILY_CAP_ROLLUP_LABEL);
+ const newIssue = await github.rest.issues.create({
+ owner,
+ repo,
+ title: DAILY_CAP_ROLLUP_TITLE,
+ body,
+ labels: ["agentic-workflows", DAILY_CAP_ROLLUP_LABEL],
+ });
+ core.info(`✓ Created daily cap rollup issue #${newIssue.data.number}: ${newIssue.data.html_url}`);
+ return { number: newIssue.data.number, html_url: newIssue.data.html_url };
+ } catch (err) {
+ core.warning(`Could not create daily cap rollup issue: ${getErrorMessage(err)}`);
+ return null;
+ }
+}
+
/**
* Detect a failure cascade and, when one is active, create/update a rollup issue
* and add the `cascade-suspected` label to every issue in the cascade window
@@ -2047,6 +2224,7 @@ async function main() {
const checkoutPRSuccess = process.env.GH_AW_CHECKOUT_PR_SUCCESS || "";
const timeoutMinutes = process.env.GH_AW_TIMEOUT_MINUTES || "";
const { effectiveTokens, maxEffectiveTokens, effectiveTokensRateLimitError } = resolveEffectiveTokensFailureState();
+ const { aiCredits, maxAICredits, aiCreditsRateLimitError } = resolveAICreditsFailureState();
const inferenceAccessError = process.env.GH_AW_INFERENCE_ACCESS_ERROR === "true";
const mcpPolicyError = process.env.GH_AW_MCP_POLICY_ERROR === "true";
const agenticEngineTimeout = process.env.GH_AW_AGENTIC_ENGINE_TIMEOUT === "true";
@@ -2235,6 +2413,15 @@ async function main() {
core.info("Missing data report-as-failure is disabled - missing_data signals will not trigger failure handling");
}
+ const engineId = String(process.env.GH_AW_ENGINE_ID || "")
+ .trim()
+ .toLowerCase();
+ const toolDenialsExceededEvents = engineId === "copilot" ? loadToolDenialsExceededEvents() : [];
+ const hasToolDenialsExceeded = toolDenialsExceededEvents.length > 0;
+ if (hasToolDenialsExceeded) {
+ core.info(`Detected ${toolDenialsExceededEvents.length} guard.tool_denials_exceeded event(s) from Copilot SDK events.jsonl`);
+ }
+
// Detect cache-miss misconfiguration: the agent reported a missing_data with reason
// "cache_memory_miss" while cache-memory was configured and available. This indicates the
// prompt is referencing an incorrect path inside the cache directory.
@@ -2272,17 +2459,19 @@ async function main() {
!hasReportIncomplete &&
!hasCacheMissMisconfiguration &&
!effectiveTokensRateLimitError &&
+ !aiCreditsRateLimitError &&
!hasMissingTool &&
- !hasMissingData
+ !hasMissingData &&
+ !hasToolDenialsExceeded
) {
core.info(
- `Agent job did not fail and no assignment/discussion/code-push/push-repo-memory/app-token/lockdown/stale-lock-file/daily-workflow-et/report-incomplete/cache-miss/missing-tool/missing-data errors and has safe outputs (conclusion: ${agentConclusion}), skipping failure handling`
+ `Agent job did not fail and no assignment/discussion/code-push/push-repo-memory/app-token/lockdown/stale-lock-file/daily-workflow-et/ai-credits/report-incomplete/cache-miss/missing-tool/missing-data/tool-denials-exceeded errors and has safe outputs (conclusion: ${agentConclusion}), skipping failure handling`
);
return;
}
- // If we only have noop outputs (and no report_incomplete or cache-miss or missing-tool/data), skip failure handling
- if (hasOnlyNoopOutputs && !hasReportIncomplete && !hasCacheMissMisconfiguration && !hasMissingTool && !hasMissingData) {
+ // If we only have noop outputs (and no report_incomplete/cache-miss/missing-tool/data/tool-denials-exceeded), skip failure handling
+ if (hasOnlyNoopOutputs && !hasReportIncomplete && !hasCacheMissMisconfiguration && !hasMissingTool && !hasMissingData && !hasToolDenialsExceeded) {
core.info("Agent completed with only noop outputs - skipping failure handling");
return;
}
@@ -2371,6 +2560,7 @@ async function main() {
hasMissingSafeOutputs,
hasReportIncomplete,
hasMissingTool,
+ hasToolDenialsExceeded,
hasMissingData,
hasCacheMissMisconfiguration,
secretVerificationFailed: secretVerificationResult === "failed",
@@ -2378,6 +2568,7 @@ async function main() {
mcpPolicyError,
modelNotSupportedError,
effectiveTokensRateLimitError,
+ aiCreditsRateLimitError,
hasAppTokenMintingFailed,
hasLockdownCheckFailed,
hasStaleLockFileFailed,
@@ -2458,6 +2649,8 @@ async function main() {
// Build permission denied context (denied commands list + fix prompt)
const permissionDeniedContext = buildPermissionDeniedContext(agentOutputResult.items, workflowID);
+ // Build tool-denials-exceeded guard context from events.jsonl
+ const toolDenialsExceededContext = buildToolDenialsExceededContext(toolDenialsExceededEvents, workflowID);
// Build report_incomplete context
const reportIncompleteContext = buildReportIncompleteContext(agentOutputResult.items);
@@ -2492,6 +2685,7 @@ async function main() {
// Build model not supported error context
const modelNotSupportedErrorContext = buildModelNotSupportedErrorContext(modelNotSupportedError);
const effectiveTokensRateLimitErrorContext = buildEffectiveTokensRateLimitErrorContext(effectiveTokensRateLimitError, effectiveTokens, maxEffectiveTokens, runUrl);
+ const aiCreditsRateLimitErrorContext = buildAICreditsRateLimitErrorContext(aiCreditsRateLimitError, aiCredits, maxAICredits, runUrl);
// Build GitHub App token minting failure context
const appTokenMintingFailedContext = buildAppTokenMintingFailedContext(hasAppTokenMintingFailed);
@@ -2531,6 +2725,7 @@ async function main() {
missing_data_context: missingDataContext,
missing_tool_context: missingToolContext,
permission_denied_context: permissionDeniedContext,
+ tool_denials_exceeded_context: toolDenialsExceededContext,
report_incomplete_context: reportIncompleteContext,
missing_safe_outputs_context: missingSafeOutputsContext,
engine_failure_context: engineFailureContext,
@@ -2540,6 +2735,7 @@ async function main() {
mcp_policy_error_context: mcpPolicyErrorContext,
model_not_supported_error_context: modelNotSupportedErrorContext,
effective_tokens_rate_limit_error_context: effectiveTokensRateLimitErrorContext,
+ ai_credits_rate_limit_error_context: aiCreditsRateLimitErrorContext,
app_token_minting_failed_context: appTokenMintingFailedContext,
lockdown_check_failed_context: lockdownCheckFailedContext,
stale_lock_file_failed_context: staleLockFileFailedContext,
@@ -2589,6 +2785,33 @@ async function main() {
const summary = cappedCategories.map(({ category, count }) => `${category} (${count}/${FAILURE_ISSUE_CATEGORY_DAILY_CAP})`).join(", ");
core.warning(`Daily per-category issue cap reached for ${summary}.`);
core.info(`Summarize-and-stop: skipping new issue creation because category cap was reached in the last ${FAILURE_ISSUE_DEDUP_WINDOW_HOURS}h.`);
+
+ // Create or reuse a centralized rollup issue and add a comment so the failure is
+ // still tracked rather than silently dropped.
+ try {
+ const rollupIssue = await findOrCreateDailyCapRollupIssue(owner, repo);
+ if (rollupIssue) {
+ const commentBody = sanitizeContent(
+ renderTemplateFromFile(getPromptPath("daily_cap_rollup_comment.md"), {
+ workflow_name: workflowName,
+ run_url: runUrl,
+ summary,
+ cap: FAILURE_ISSUE_CATEGORY_DAILY_CAP,
+ window_hours: FAILURE_ISSUE_DEDUP_WINDOW_HOURS,
+ }),
+ { maxLength: 65000 }
+ );
+ await github.rest.issues.createComment({
+ owner,
+ repo,
+ issue_number: rollupIssue.number,
+ body: commentBody,
+ });
+ core.info(`✓ Added cap-exceeded comment to rollup issue #${rollupIssue.number}: ${rollupIssue.html_url}`);
+ }
+ } catch (err) {
+ core.warning(`Could not update daily cap rollup issue: ${getErrorMessage(err)}`);
+ }
return;
}
@@ -2648,6 +2871,8 @@ async function main() {
// Build permission denied context (denied commands list + fix prompt)
const permissionDeniedContext = buildPermissionDeniedContext(agentOutputResult.items, workflowID);
+ // Build tool-denials-exceeded guard context from events.jsonl
+ const toolDenialsExceededContext = buildToolDenialsExceededContext(toolDenialsExceededEvents, workflowID);
// Build missing safe outputs context
let missingSafeOutputsContext = "";
@@ -2680,6 +2905,7 @@ async function main() {
// Build model not supported error context
const modelNotSupportedErrorContext = buildModelNotSupportedErrorContext(modelNotSupportedError);
const effectiveTokensRateLimitErrorContext = buildEffectiveTokensRateLimitErrorContext(effectiveTokensRateLimitError, effectiveTokens, maxEffectiveTokens, runUrl);
+ const aiCreditsRateLimitErrorContext = buildAICreditsRateLimitErrorContext(aiCreditsRateLimitError, aiCredits, maxAICredits, runUrl);
// Build GitHub App token minting failure context
const appTokenMintingFailedContext = buildAppTokenMintingFailedContext(hasAppTokenMintingFailed);
@@ -2720,6 +2946,7 @@ async function main() {
missing_data_context: missingDataContext,
missing_tool_context: missingToolContext,
permission_denied_context: permissionDeniedContext,
+ tool_denials_exceeded_context: toolDenialsExceededContext,
report_incomplete_context: reportIncompleteContext,
missing_safe_outputs_context: missingSafeOutputsContext,
engine_failure_context: engineFailureContext,
@@ -2729,6 +2956,7 @@ async function main() {
mcp_policy_error_context: mcpPolicyErrorContext,
model_not_supported_error_context: modelNotSupportedErrorContext,
effective_tokens_rate_limit_error_context: effectiveTokensRateLimitErrorContext,
+ ai_credits_rate_limit_error_context: aiCreditsRateLimitErrorContext,
app_token_minting_failed_context: appTokenMintingFailedContext,
lockdown_check_failed_context: lockdownCheckFailedContext,
stale_lock_file_failed_context: staleLockFileFailedContext,
@@ -2818,6 +3046,8 @@ module.exports = {
buildMissingDataContext,
buildMissingToolContext,
buildPermissionDeniedContext,
+ loadToolDenialsExceededEvents,
+ buildToolDenialsExceededContext,
buildCredentialAuthErrorContext,
buildEffectiveTokensRateLimitErrorContext,
hasEngineRateLimit429Signal,
diff --git a/setup/js/handle_detection_runs.cjs b/setup/js/handle_detection_runs.cjs
index 91fe9329..526d952e 100644
--- a/setup/js/handle_detection_runs.cjs
+++ b/setup/js/handle_detection_runs.cjs
@@ -7,8 +7,6 @@ const { ERR_API } = require("./error_codes.cjs");
const { sanitizeContent } = require("./sanitize_content.cjs");
const { generateFooterWithExpiration } = require("./ephemerals.cjs");
const { renderTemplateFromFile, getPromptPath } = require("./messages_core.cjs");
-const { getEffectiveTokensSuffix } = require("./effective_tokens.cjs");
-
/**
* Search for or create the parent issue for all agentic workflow detection runs.
* @returns {Promise<{number: number, node_id: string}>} Parent issue number and node ID
@@ -108,16 +106,11 @@ async function main() {
// Load and render comment template from file
const commentTemplatePath = getPromptPath("detection_runs_comment.md");
-
- // Compute effective tokens suffix from environment variable (set by parse_token_usage.cjs / parse_mcp_gateway_log.cjs)
- const effectiveTokensSuffix = getEffectiveTokensSuffix();
-
const commentBody = renderTemplateFromFile(commentTemplatePath, {
workflow_name: workflowName,
conclusion: detectionConclusion,
reason: detectionReason || "unknown",
run_url: runUrl,
- effective_tokens_suffix: effectiveTokensSuffix,
});
// Sanitize the full comment body
diff --git a/setup/js/handle_noop_message.cjs b/setup/js/handle_noop_message.cjs
index bd522b76..a91853d2 100644
--- a/setup/js/handle_noop_message.cjs
+++ b/setup/js/handle_noop_message.cjs
@@ -9,8 +9,6 @@ const { generateFooterWithExpiration } = require("./ephemerals.cjs");
const { renderTemplateFromFile, getPromptPath } = require("./messages_core.cjs");
const { loadAgentOutput } = require("./load_agent_output.cjs");
const { isStagedMode } = require("./safe_output_helpers.cjs");
-const { getEffectiveTokensSuffix } = require("./effective_tokens.cjs");
-
/**
* Search for or create the parent issue for all agentic workflow no-op runs
* @returns {Promise<{number: number, node_id: string}>} Parent issue number and node ID
@@ -185,15 +183,10 @@ async function main() {
// Load and render comment template from file
const commentTemplatePath = getPromptPath("noop_comment.md");
-
- // Compute effective tokens suffix from environment variable (set by parse_token_usage.cjs / parse_mcp_gateway_log.cjs)
- const effectiveTokensSuffix = getEffectiveTokensSuffix();
-
const commentBody = renderTemplateFromFile(commentTemplatePath, {
workflow_name: workflowName,
message: noopMessage,
run_url: runUrl,
- effective_tokens_suffix: effectiveTokensSuffix,
});
// Sanitize the full comment body
diff --git a/setup/js/mcp_http_transport.cjs b/setup/js/mcp_http_transport.cjs
index 4d4baa41..2e6ef663 100644
--- a/setup/js/mcp_http_transport.cjs
+++ b/setup/js/mcp_http_transport.cjs
@@ -41,11 +41,12 @@ class MCPServer {
* @param {Object} [options] - Server options
* @param {Object} [options.capabilities] - Server capabilities
* @param {string} [options.logDir] - Log directory path
+ * @param {(toolName: string, args: any) => any} [options.normalizeArguments] - Optional tool argument normalizer
*/
constructor(serverInfo, options = {}) {
// Extract logDir for createServer, keep capabilities for this class
- const { capabilities, logDir } = options;
- this._coreServer = createServer(serverInfo, { logDir });
+ const { capabilities, logDir, normalizeArguments } = options;
+ this._coreServer = createServer(serverInfo, { logDir, normalizeArguments });
this.serverInfo = serverInfo;
this.capabilities = capabilities || { tools: {} };
this.tools = new Map();
diff --git a/setup/js/mcp_server_core.cjs b/setup/js/mcp_server_core.cjs
index 6f40fe76..2d4878d3 100644
--- a/setup/js/mcp_server_core.cjs
+++ b/setup/js/mcp_server_core.cjs
@@ -66,6 +66,7 @@ const encoder = new TextEncoder();
* @property {string} [logDir] - Optional log directory
* @property {string} [logFilePath] - Optional log file path
* @property {boolean} logFileInitialized - Whether log file has been initialized
+ * @property {(toolName: string, args: any) => any} [normalizeArguments] - Optional tool argument normalizer
*/
/**
@@ -187,6 +188,7 @@ function createReplyErrorFunction(server) {
* @param {ServerInfo} serverInfo - Server information (name and version)
* @param {Object} [options] - Optional server configuration
* @param {string} [options.logDir] - Directory for log file (optional)
+ * @param {(toolName: string, args: any) => any} [options.normalizeArguments] - Optional tool argument normalizer
* @returns {MCPServer} The MCP server instance
*/
function createServer(serverInfo, options = {}) {
@@ -206,6 +208,7 @@ function createServer(serverInfo, options = {}) {
logDir,
logFilePath,
logFileInitialized: false,
+ normalizeArguments: typeof options.normalizeArguments === "function" ? options.normalizeArguments : undefined,
};
// Initialize functions with references to server
@@ -557,6 +560,21 @@ function containsAtFilepathReference(value) {
return false;
}
+/**
+ * Normalize tool arguments when the server provides a custom normalizer.
+ * @param {MCPServer} server
+ * @param {string} toolName
+ * @param {any} args
+ * @returns {any}
+ */
+function normalizeToolArguments(server, toolName, args) {
+ if (typeof server.normalizeArguments !== "function") {
+ return args;
+ }
+ const normalized = server.normalizeArguments(toolName, args);
+ return normalized == null ? args : normalized;
+}
+
/**
* Handle an incoming JSON-RPC request and return a response (for HTTP transport)
* This function is compatible with the MCPServer class's handleRequest method.
@@ -603,19 +621,13 @@ async function handleRequest(server, request, defaultHandler) {
result = { tools: list };
} else if (method === "tools/call") {
const name = params?.name;
- const args = params?.arguments ?? {};
+ const rawArgs = params?.arguments ?? {};
if (!name || typeof name !== "string") {
throw {
code: -32602,
message: "Invalid params: 'name' must be a string",
};
}
- if (containsAtFilepathReference(args)) {
- throw {
- code: -32602,
- message: "Invalid params: local file references using @filepath notation are not supported by this MCP server. Do not attempt to inline files. Provide the needed content directly in arguments instead.",
- };
- }
const tool = server.tools[normalizeTool(name)];
if (!tool) {
// Find similar tools to suggest
@@ -645,6 +657,14 @@ async function handleRequest(server, request, defaultHandler) {
};
}
+ const args = normalizeToolArguments(server, tool.name, rawArgs);
+ if (containsAtFilepathReference(args)) {
+ throw {
+ code: -32602,
+ message: "Invalid params: local file references using @filepath notation are not supported by this MCP server. Do not attempt to inline files. Provide the needed content directly in arguments instead.",
+ };
+ }
+
const missing = validateRequiredFields(args, tool.inputSchema);
if (missing.length) {
const hasRequiredFields = tool.inputSchema && Array.isArray(tool.inputSchema.required) && tool.inputSchema.required.length > 0;
@@ -760,15 +780,11 @@ async function handleMessage(server, req, defaultHandler) {
server.replyResult(id, { tools: list });
} else if (method === "tools/call") {
const name = params?.name;
- const args = params?.arguments ?? {};
+ const rawArgs = params?.arguments ?? {};
if (!name || typeof name !== "string") {
server.replyError(id, -32602, "Invalid params: 'name' must be a string");
return;
}
- if (containsAtFilepathReference(args)) {
- server.replyError(id, -32602, "Invalid params: local file references using @filepath notation are not supported by this MCP server. Do not attempt to inline files. Provide the needed content directly in arguments instead.");
- return;
- }
const tool = server.tools[normalizeTool(name)];
if (!tool) {
// Find similar tools to suggest
@@ -794,6 +810,12 @@ async function handleMessage(server, req, defaultHandler) {
return;
}
+ const args = normalizeToolArguments(server, tool.name, rawArgs);
+ if (containsAtFilepathReference(args)) {
+ server.replyError(id, -32602, "Invalid params: local file references using @filepath notation are not supported by this MCP server. Do not attempt to inline files. Provide the needed content directly in arguments instead.");
+ return;
+ }
+
const missing = validateRequiredFields(args, tool.inputSchema);
if (missing.length) {
const hasRequiredFields = tool.inputSchema && Array.isArray(tool.inputSchema.required) && tool.inputSchema.required.length > 0;
diff --git a/setup/js/messages_footer.cjs b/setup/js/messages_footer.cjs
index 5fa3ff88..a3a1d68e 100644
--- a/setup/js/messages_footer.cjs
+++ b/setup/js/messages_footer.cjs
@@ -61,6 +61,28 @@ function parsePositiveAIC(raw) {
return Number.isFinite(parsed) && parsed > 0 ? parsed : undefined;
}
+/**
+ * @param {string|undefined} raw
+ * @returns {number|undefined}
+ */
+function parsePositiveAmbientContext(raw) {
+ const parsed = raw ? Number.parseInt(raw, 10) : NaN;
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : undefined;
+}
+
+/**
+ * @returns {{ ambientContext: number|undefined, ambientContextFormatted: string|undefined, ambientContextSuffix: string }}
+ */
+function getAmbientContextFromEnv() {
+ const ambientContext = parsePositiveAmbientContext(process.env.GH_AW_AMBIENT_CONTEXT);
+ const ambientContextFormatted = typeof ambientContext === "number" ? formatET(ambientContext) : undefined;
+ return {
+ ambientContext,
+ ambientContextFormatted,
+ ambientContextSuffix: ambientContextFormatted ? ` · ⊞ ${ambientContextFormatted} ambient context` : "",
+ };
+}
+
/**
* @param {string} label
* @param {number|undefined} value
@@ -68,10 +90,11 @@ function parsePositiveAIC(raw) {
*/
function buildAICEntry(label, value) {
const formatted = typeof value === "number" ? formatAIC(value) : undefined;
+ const labelPrefix = label ? `${label} ` : "";
return {
value,
formatted,
- suffix: formatted ? ` · ${label} ${formatted} AIC` : "",
+ suffix: formatted ? ` · ${labelPrefix}${formatted} AIC` : "",
};
}
@@ -94,8 +117,8 @@ function getAICFromEnv() {
const totalAIC = parsePositiveAIC(process.env.GH_AW_AIC);
const agentAIC = parsePositiveAIC(process.env.GH_AW_AGENT_AIC);
const threatDetectionAIC = parsePositiveAIC(process.env.GH_AW_THREAT_DETECTION_AIC);
- const agentEntry = buildAICEntry("agent", agentAIC);
- const threatDetectionEntry = buildAICEntry("threat-detection", threatDetectionAIC);
+ const agentEntry = buildAICEntry("", agentAIC);
+ const threatDetectionEntry = buildAICEntry("⌖", threatDetectionAIC);
const useBreakdown = threatDetectionEntry.suffix.length > 0;
const aiCredits = useBreakdown ? (agentAIC || 0) + (threatDetectionAIC || 0) : typeof totalAIC === "number" ? totalAIC : agentAIC;
const aiCreditsFormatted = typeof aiCredits === "number" ? formatAIC(aiCredits) : undefined;
@@ -181,8 +204,10 @@ function getFooterMessage(ctx) {
threatDetectionAiCreditsFormatted,
threatDetectionAiCreditsSuffix,
} = getAICFromEnv();
+ const { ambientContext: envAmbientContext, ambientContextFormatted: envAmbientContextFormatted, ambientContextSuffix: envAmbientContextSuffix } = getAmbientContextFromEnv();
const effectiveTokens = ctx.effectiveTokens ?? envEffectiveTokens;
const aiCredits = ctx.aiCredits ?? envAIC;
+ const ambientContext = envAmbientContext;
// Pre-compute history_link as a ready-to-use markdown suffix (empty string when unavailable)
const historyLink = ctx.historyUrl ? ` · [◷](${ctx.historyUrl})` : "";
@@ -230,6 +255,9 @@ function getFooterMessage(ctx) {
effectiveTokensSuffix: finalEffectiveTokensSuffix,
aiCreditsFormatted,
aiCreditsSuffix,
+ ambientContext,
+ ambientContextFormatted: envAmbientContextFormatted,
+ ambientContextSuffix: envAmbientContextSuffix,
agentAiCredits,
agentAiCreditsFormatted,
agentAiCreditsSuffix,
@@ -249,8 +277,15 @@ function getFooterMessage(ctx) {
if (ctx.triggeringNumber) {
defaultFooter += " for issue #{triggering_number}";
}
+ const metricSuffixes = [];
if (aiCredits) {
- defaultFooter += aiCreditsSuffix;
+ metricSuffixes.push(aiCreditsSuffix);
+ }
+ if (ambientContext) {
+ metricSuffixes.push(envAmbientContextSuffix);
+ }
+ if (metricSuffixes.length > 0) {
+ defaultFooter += metricSuffixes.join("");
}
// Append history link when available
if (ctx.historyUrl) {
diff --git a/setup/js/model_costs.cjs b/setup/js/model_costs.cjs
index 3b4fce57..79607674 100644
--- a/setup/js/model_costs.cjs
+++ b/setup/js/model_costs.cjs
@@ -69,7 +69,8 @@ function normalizeProvider(provider) {
const normalized = String(provider || "")
.trim()
.toLowerCase();
- return normalized === "github" ? "github-copilot" : normalized;
+ if (normalized === "github" || normalized === "copilot") return "github-copilot";
+ return normalized;
}
/**
@@ -119,7 +120,16 @@ function findModelPricing(provider, model) {
const comparableModel = normalizeComparableID(model);
if (!normalizedModel) return null;
- const fullID = normalizedModel.includes("/") ? normalizedModel : normalizedProvider ? `${normalizedProvider}/${normalizedModel}` : "";
+ // When the model name embeds a provider prefix (e.g., "copilot/claude-sonnet-4.6"),
+ // normalize that embedded prefix so it matches the catalog (e.g., "github-copilot/claude-sonnet-4.6").
+ let fullID;
+ if (normalizedModel.includes("/")) {
+ const slashIdx = normalizedModel.indexOf("/");
+ const embeddedProvider = normalizeProvider(normalizedModel.slice(0, slashIdx));
+ fullID = `${embeddedProvider}/${normalizedModel.slice(slashIdx + 1)}`;
+ } else {
+ fullID = normalizedProvider ? `${normalizedProvider}/${normalizedModel}` : "";
+ }
const comparableFullID = normalizeComparableID(fullID);
for (const entry of catalog) {
diff --git a/setup/js/parse_mcp_gateway_log.cjs b/setup/js/parse_mcp_gateway_log.cjs
index f2d3258c..b2cf15a5 100644
--- a/setup/js/parse_mcp_gateway_log.cjs
+++ b/setup/js/parse_mcp_gateway_log.cjs
@@ -5,7 +5,7 @@ const fs = require("fs");
const { getErrorMessage } = require("./error_helpers.cjs");
const { displayDirectories } = require("./display_file_helpers.cjs");
const { ERR_PARSE, ERR_SYSTEM } = require("./error_codes.cjs");
-const { computeEffectiveTokens, formatET, formatModelEmojiAlias } = require("./effective_tokens.cjs");
+const { computeEffectiveTokens, formatModelEmojiAlias } = require("./effective_tokens.cjs");
const { computeInferenceAIC, formatAIC } = require("./model_costs.cjs");
const { generateUnifiedTimelineSummary } = require("./unified_timeline.cjs");
@@ -52,7 +52,9 @@ function formatDurationMs(ms) {
* Parses token-usage.jsonl content and returns an aggregated summary.
* Computes effective tokens (ET) per model using merged multipliers, env fallback, then built-in multipliers.
* @param {string} jsonlContent - The token-usage.jsonl file content
- * @returns {{totalInputTokens: number, totalOutputTokens: number, totalCacheReadTokens: number, totalCacheWriteTokens: number, totalRequests: number, totalDurationMs: number, totalEffectiveTokens: number, totalAIC: number, byModel: Object, entries: Array} | null}
+ * @returns {{totalInputTokens: number, totalOutputTokens: number, totalCacheReadTokens: number, totalCacheWriteTokens: number, totalRequests: number, totalDurationMs: number, totalEffectiveTokens: number, totalAIC: number, ambientContextTokens: number|undefined, byModel: Object, entries: Array} | null}
+ * ambientContextTokens records first-request context size as:
+ * input_tokens + ((cache_read_tokens + cache_write_tokens) / 10).
*/
function parseTokenUsageJsonl(jsonlContent) {
const summary = {
@@ -64,6 +66,7 @@ function parseTokenUsageJsonl(jsonlContent) {
totalDurationMs: 0,
totalEffectiveTokens: 0,
totalAIC: 0,
+ ambientContextTokens: undefined,
byModel: {},
/** @type {{ model: string, provider: string, inputTokens: number, outputTokens: number, cacheReadTokens: number, cacheWriteTokens: number, reasoningTokens: number, durationMs: number, deltaET: number, deltaAIC: number }[]} */
entries: [],
@@ -90,6 +93,10 @@ function parseTokenUsageJsonl(jsonlContent) {
summary.totalCacheWriteTokens += cacheWriteTokens;
summary.totalRequests++;
summary.totalDurationMs += durationMs;
+ if (summary.ambientContextTokens === undefined) {
+ const cacheTokens = cacheReadTokens + cacheWriteTokens;
+ summary.ambientContextTokens = inputTokens + cacheTokens / 10;
+ }
const model = entry.model || "unknown";
summary.byModel[model] ??= {
@@ -162,9 +169,8 @@ function parseTokenUsageJsonl(jsonlContent) {
/**
* Generates a markdown summary section for token usage data.
- * Renders one row per turn in chronological order with per-turn delta ET (ΔET),
- * a running compounded ET total (ET), per-turn delta AIC (ΔAIC), a running
- * compounded AIC total (AIC), followed by an aggregate totals row.
+ * Renders one row per request in chronological order with per-request AI credits,
+ * a running AI credits total, followed by an aggregate totals row and legend.
* @param {{totalInputTokens: number, totalOutputTokens: number, totalCacheReadTokens: number, totalCacheWriteTokens: number, totalRequests: number, totalDurationMs: number, totalEffectiveTokens: number, totalAIC: number, byModel: Object, entries: Array} | null} summary
* @returns {string} Markdown section, or empty string if no data
*/
@@ -172,29 +178,27 @@ function generateTokenUsageSummary(summary) {
if (!summary || summary.totalRequests === 0) return "";
const lines = [];
- lines.push("| # | Alias | Input | Output | Cache Read | Cache Write | ΔET | ET | ΔAIC | AIC | Duration |");
- lines.push("|--:|-------|------:|-------:|-----------:|------------:|----:|---:|-----:|----:|---------:|");
+ lines.push("| # | Alias | Input | Output | Cache Read | Cache Write | ΔAI Credits | AI Credits | Duration |");
+ lines.push("|--:|-------|------:|-------:|-----------:|------------:|-------------:|-----------:|---------:|");
const entries = summary.entries || [];
- let compoundedET = 0;
let compoundedAIC = 0;
for (let i = 0; i < entries.length; i++) {
const entry = entries[i];
- const deltaET = Math.round(entry.deltaET || 0);
- compoundedET += deltaET;
const deltaAIC = entry.deltaAIC || 0;
compoundedAIC += deltaAIC;
lines.push(
- `| ${i + 1} | ${formatModelEmojiAlias(entry.model) || entry.model} | ${entry.inputTokens.toLocaleString()} | ${entry.outputTokens.toLocaleString()} | ${entry.cacheReadTokens.toLocaleString()} | ${entry.cacheWriteTokens.toLocaleString()} | ${formatET(deltaET)} | ${formatET(compoundedET)} | ${formatAIC(deltaAIC)} | ${formatAIC(compoundedAIC)} | ${formatDurationMs(entry.durationMs)} |`
+ `| ${i + 1} | ${formatModelEmojiAlias(entry.model) || entry.model} | ${entry.inputTokens.toLocaleString()} | ${entry.outputTokens.toLocaleString()} | ${entry.cacheReadTokens.toLocaleString()} | ${entry.cacheWriteTokens.toLocaleString()} | ${formatAIC(deltaAIC)} | ${formatAIC(compoundedAIC)} | ${formatDurationMs(entry.durationMs)} |`
);
}
- const totalET = formatET(Math.round(summary.totalEffectiveTokens || 0));
const totalAIC = formatAIC(summary.totalAIC || 0);
lines.push(
- `| **Total** | | **${summary.totalInputTokens.toLocaleString()}** | **${summary.totalOutputTokens.toLocaleString()}** | **${summary.totalCacheReadTokens.toLocaleString()}** | **${summary.totalCacheWriteTokens.toLocaleString()}** | | **${totalET}** | | **${totalAIC}** | **${formatDurationMs(summary.totalDurationMs)}** |`
+ `| **Total** | | **${summary.totalInputTokens.toLocaleString()}** | **${summary.totalOutputTokens.toLocaleString()}** | **${summary.totalCacheReadTokens.toLocaleString()}** | **${summary.totalCacheWriteTokens.toLocaleString()}** | | **${totalAIC}** | **${formatDurationMs(summary.totalDurationMs)}** |`
);
+ lines.push("");
+ lines.push("Legend: `Alias` shows the model shorthand used in the table. `ΔAI Credits` is the per-request cost, and `AI Credits` is the running total computed with the current AI credits pricing model.");
lines.push("");
return lines.join("\n") + "\n";
@@ -232,6 +236,12 @@ function writeStepSummaryWithTokenUsage(coreObj) {
coreObj.setOutput("aic", roundedAIC);
coreObj.info(`AI Credits: ${roundedAIC}`);
}
+ if (parsedSummary && typeof parsedSummary.ambientContextTokens === "number" && parsedSummary.ambientContextTokens > 0) {
+ const roundedAmbientContext = String(Math.round(parsedSummary.ambientContextTokens));
+ coreObj.exportVariable("GH_AW_AMBIENT_CONTEXT", roundedAmbientContext);
+ coreObj.setOutput("ambient_context", roundedAmbientContext);
+ coreObj.info(`Ambient context: ${roundedAmbientContext}`);
+ }
}
}
diff --git a/setup/js/parse_token_usage.cjs b/setup/js/parse_token_usage.cjs
index b98e6798..166f6d74 100644
--- a/setup/js/parse_token_usage.cjs
+++ b/setup/js/parse_token_usage.cjs
@@ -92,6 +92,35 @@ function getSummaryTitle() {
return title && title.trim() ? title.trim() : DEFAULT_SUMMARY_TITLE;
}
+/**
+ * Builds the token usage section for the GitHub step summary.
+ * @param {string} title
+ * @param {string} markdown
+ * @returns {string}
+ */
+function buildStepSummarySection(title, markdown) {
+ return `### ${title}\n\n\nPer-request AI credits and token totals
\n\n${markdown} \n\n`;
+}
+
+/**
+ * Appends the token usage section to GITHUB_STEP_SUMMARY when available.
+ * Falls back to the Actions summary API when the summary path is unavailable.
+ * @param {string} title
+ * @param {string} markdown
+ * @returns {Promise}
+ */
+async function appendStepSummarySection(title, markdown) {
+ const section = buildStepSummarySection(title, markdown);
+ const summaryPath = process.env.GITHUB_STEP_SUMMARY;
+ if (summaryPath) {
+ fs.appendFileSync(summaryPath, section, "utf8");
+ return;
+ }
+
+ core.summary.addRaw(section, true);
+ await core.summary.write();
+}
+
/**
* Main function to parse token usage and write the step summary.
*/
@@ -111,13 +140,11 @@ async function main() {
core.info("Token usage file contained no valid entries");
return;
}
-
const markdown = generateTokenUsageSummary(summary);
if (markdown.length > 0) {
- core.summary.addDetails(getSummaryTitle(), "\n\n" + markdown);
+ await appendStepSummarySection(getSummaryTitle(), markdown);
}
- await core.summary.write();
core.info("Token usage summary appended to step summary");
// Write agent_usage.json so the aggregated totals are bundled in the agent
@@ -141,6 +168,7 @@ async function main() {
output_tokens: summary.totalOutputTokens,
cache_read_tokens: summary.totalCacheReadTokens,
cache_write_tokens: summary.totalCacheWriteTokens,
+ ambient_context: Math.round(summary.ambientContextTokens || 0),
effective_tokens: effectiveTokens,
ai_credits: Number((summary.totalAIC || 0).toFixed(3)),
...(primaryModel ? { primary_model: primaryModel } : {}),
@@ -160,6 +188,12 @@ async function main() {
core.setOutput("aic", aic);
core.info(`AI Credits: ${aic}`);
}
+ if (typeof summary.ambientContextTokens === "number" && summary.ambientContextTokens > 0) {
+ const ambientContext = String(Math.round(summary.ambientContextTokens));
+ core.exportVariable("GH_AW_AMBIENT_CONTEXT", ambientContext);
+ core.setOutput("ambient_context", ambientContext);
+ core.info(`Ambient context: ${ambientContext}`);
+ }
} catch (error) {
core.setFailed(`${ERR_PARSE}: ${getErrorMessage(error)}`);
}
@@ -173,6 +207,8 @@ if (typeof module !== "undefined" && module.exports) {
extractRequestId,
readDedupedTokenUsage,
getSummaryTitle,
+ buildStepSummarySection,
+ appendStepSummarySection,
TOKEN_USAGE_AUDIT_PATH,
TOKEN_USAGE_PATH,
TOKEN_USAGE_PATHS,
diff --git a/setup/js/permission_denied_helpers.cjs b/setup/js/permission_denied_helpers.cjs
index 74f94716..fbdd02c9 100644
--- a/setup/js/permission_denied_helpers.cjs
+++ b/setup/js/permission_denied_helpers.cjs
@@ -38,7 +38,22 @@ function extractDeniedCommands(output) {
if (!output) return [];
const lines = output.split("\n");
const deniedCommands = new Set();
+ const permissionSummaryPatterns = [/permission denied by workflow tool permissions:\s*(.+?)\s*$/i, /tool denial \d+\/\d+:\s*permission denied:\s*(.+?)\s*$/i];
for (let i = 0; i < lines.length; i++) {
+ let capturedFromLine = false;
+ for (const pattern of permissionSummaryPatterns) {
+ const summaryMatch = lines[i].match(pattern);
+ if (summaryMatch && summaryMatch[1] && summaryMatch[1].trim()) {
+ deniedCommands.add(summaryMatch[1].trim());
+ capturedFromLine = true;
+ break;
+ }
+ }
+
+ if (capturedFromLine) {
+ continue;
+ }
+
if (/\bpermission denied\b/i.test(lines[i])) {
// Look back up to 3 lines for a command displayed with the
// box-drawing pipe marker (│ U+2502) or plain pipe (|).
diff --git a/setup/js/push_signed_commits.cjs b/setup/js/push_signed_commits.cjs
index dbf3e0f8..845c3ce5 100644
--- a/setup/js/push_signed_commits.cjs
+++ b/setup/js/push_signed_commits.cjs
@@ -9,6 +9,7 @@
const { ERR_API } = require("./error_codes.cjs");
const { loadTemporaryIdMapFromResolved, replaceTemporaryIdReferencesInPatch, TEMPORARY_ID_CANDIDATE_REFERENCE_PATTERN } = require("./temporary_id.cjs");
+const { checkFileProtectionPostApply } = require("./manifest_file_helpers.cjs");
const OID_PATTERN = /^[0-9a-f]{40}$/i;
/** Sentinel error class used to signal that the commit range contains a shape
@@ -27,6 +28,15 @@ class PushSignedCommitsUnsupportedShape extends Error {
}
}
+/** Sentinel error class for synthesized-payload policy violations. */
+class PushSignedCommitsPolicyViolation extends Error {
+ /** @param {string} message */
+ constructor(message) {
+ super(message);
+ this.name = "PushSignedCommitsPolicyViolation";
+ }
+}
+
/**
* Unescape a C-quoted path returned by `git diff-tree --raw`.
*
@@ -183,6 +193,43 @@ async function pushBranchAndResolveHead({ branch, cwd, gitAuthEnv }) {
return resolveLocalHeadSha(cwd);
}
+/**
+ * Enforce limits and file-protection policy on the synthesized GraphQL payload.
+ *
+ * @param {Array<{path: string, contents: string}>} additions
+ * @param {Array<{path: string}>} deletions
+ * @param {Record | undefined} validationConfig
+ */
+function validateSynthesizedFileChanges(additions, deletions, validationConfig) {
+ if (!validationConfig) {
+ return;
+ }
+
+ const uniquePaths = Array.from(new Set([...deletions.map(entry => entry.path), ...additions.map(entry => entry.path)].map(path => String(path || "").trim()).filter(Boolean)));
+
+ const maxFilesRaw = Number.parseInt(String(validationConfig.max_patch_files ?? ""), 10);
+ if (Number.isFinite(maxFilesRaw) && maxFilesRaw > 0 && uniquePaths.length > maxFilesRaw) {
+ throw new PushSignedCommitsPolicyViolation(`E003: Signed-commit payload exceeds max-patch-files (${maxFilesRaw}). ` + `Synthesized payload touches ${uniquePaths.length} file(s): ${uniquePaths.join(", ")}`);
+ }
+
+ const maxSizeKbRaw = Number.parseInt(String(validationConfig.max_patch_size ?? ""), 10);
+ if (Number.isFinite(maxSizeKbRaw) && maxSizeKbRaw > 0) {
+ const additionsBytes = additions.reduce((total, entry) => total + Buffer.from(entry.contents, "base64").length, 0);
+ const additionsKb = Math.ceil(additionsBytes / 1024);
+ if (additionsKb > maxSizeKbRaw) {
+ throw new PushSignedCommitsPolicyViolation(`E003: Signed-commit payload exceeds max-patch-size (${maxSizeKbRaw} KB). ` + `Synthesized payload additions total ${additionsKb} KB`);
+ }
+ }
+
+ const protection = checkFileProtectionPostApply(uniquePaths, {
+ ...validationConfig,
+ protected_files_policy: validationConfig.protected_files_policy ?? "request_review",
+ });
+ if (protection.action !== "allow") {
+ throw new PushSignedCommitsPolicyViolation(`Signed-commit payload violates file-protection policy (${protection.action}): ${protection.files.join(", ")}`);
+ }
+}
+
/**
* Resolve the local HEAD SHA.
*
@@ -211,9 +258,10 @@ async function resolveLocalHeadSha(cwd) {
* @param {boolean} [opts.allowGitPushFallback=true] - When false, refuse any fallback path that would use direct git push
* @param {Record} [opts.resolvedTemporaryIds] - Resolved temporary IDs map
* @param {string} [opts.currentRepo] - Repository slug used for same-repo temporary ID resolution
+ * @param {Record} [opts.validationConfig] - Optional safe-output policy config applied to synthesized GraphQL fileChanges
* @returns {Promise} SHA of the commit that landed on the target branch
*/
-async function pushSignedCommits({ githubClient, owner, repo, branch, baseRef, cwd, gitAuthEnv, signedCommits = true, allowGitPushFallback = true, resolvedTemporaryIds, currentRepo }) {
+async function pushSignedCommits({ githubClient, owner, repo, branch, baseRef, cwd, gitAuthEnv, signedCommits = true, allowGitPushFallback = true, resolvedTemporaryIds, currentRepo, validationConfig }) {
const effectiveCurrentRepo = currentRepo || `${owner}/${repo}`;
const temporaryIdMap = loadTemporaryIdMapFromResolved(resolvedTemporaryIds, {
defaultRepo: effectiveCurrentRepo,
@@ -292,18 +340,71 @@ async function pushSignedCommits({ githubClient, owner, repo, branch, baseRef, c
const fields = line.split(" ");
return { line, fields, sha: fields[0] };
});
- const revListEntries = baseRefOid !== undefined ? revListEntriesRaw.filter(entry => entry.sha !== baseRefOid) : revListEntriesRaw;
+ let revListEntries = baseRefOid !== undefined ? revListEntriesRaw.filter(entry => entry.sha !== baseRefOid) : revListEntriesRaw;
const droppedBoundaryCount = revListEntriesRaw.length - revListEntries.length;
if (baseRefOid !== undefined && droppedBoundaryCount > 0) {
core.info(`pushSignedCommits: dropped ${droppedBoundaryCount} baseRef boundary commit(s) from replay set`);
}
- const shas = revListEntries.map(entry => entry.sha);
+ let shas = revListEntries.map(entry => entry.sha);
if (shas.length === 0) {
core.info("pushSignedCommits: no new commits to push via GraphQL");
return undefined;
}
+ let remoteHeadOid;
+ try {
+ const { stdout: oidOut } = await exec.getExecOutput("git", ["ls-remote", "origin", `refs/heads/${branch}`], { cwd, env: { ...process.env, ...(gitAuthEnv || {}) } });
+ const resolved = oidOut.trim().split(/\s+/)[0];
+ if (OID_PATTERN.test(resolved)) {
+ remoteHeadOid = resolved;
+ }
+ } catch {
+ // Non-fatal. The existing branch-creation logic handles missing remote refs.
+ }
+ const firstReplayParentOid = revListEntries[0] && revListEntries[0].fields.length > 1 ? revListEntries[0].fields[1] : undefined;
+ const firstGraphqlParentOid = remoteHeadOid || baseRefOid;
+ let graphqlParentIsAncestorOfHead = true;
+ if (firstGraphqlParentOid) {
+ try {
+ const ancestryCheck = await exec.getExecOutput("git", ["merge-base", "--is-ancestor", firstGraphqlParentOid, "HEAD"], { cwd, ignoreReturnCode: true });
+ graphqlParentIsAncestorOfHead = ancestryCheck.exitCode === 0;
+ } catch {
+ // If ancestry probing fails, keep the default (true) and avoid rewrite.
+ }
+ }
+ if (firstReplayParentOid && firstGraphqlParentOid && firstReplayParentOid !== firstGraphqlParentOid && !graphqlParentIsAncestorOfHead) {
+ core.warning(`pushSignedCommits: replay parent ${firstReplayParentOid} does not match GraphQL parent ${firstGraphqlParentOid}; ` + `rebasing commit range before signed replay to avoid stale-base file synthesis`);
+ try {
+ await exec.exec("git", ["rebase", "--onto", firstGraphqlParentOid, firstReplayParentOid, "HEAD"], { cwd });
+ } catch (rebaseError) {
+ try {
+ await exec.exec("git", ["rebase", "--abort"], { cwd });
+ } catch {
+ // Ignore cleanup failures.
+ }
+ throw new Error(
+ `pushSignedCommits: failed to rebase commit range onto current GraphQL parent (${firstGraphqlParentOid}). ` +
+ `Resolve conflicts by rebasing/cherry-picking locally and retry. Root cause: ${rebaseError instanceof Error ? rebaseError.message : String(rebaseError)}`,
+ { cause: rebaseError }
+ );
+ }
+ const { stdout: rebasedRevListOut } = await exec.getExecOutput("git", ["rev-list", "--parents", "--topo-order", "--reverse", `${firstGraphqlParentOid}..HEAD`], { cwd });
+ revListEntries = rebasedRevListOut
+ .trim()
+ .split("\n")
+ .filter(Boolean)
+ .map(line => {
+ const fields = line.split(" ");
+ return { line, fields, sha: fields[0] };
+ });
+ shas = revListEntries.map(entry => entry.sha);
+ if (shas.length === 0) {
+ core.info("pushSignedCommits: no new commits to replay after rebase");
+ return undefined;
+ }
+ }
+
core.info(`pushSignedCommits: replaying ${shas.length} commit(s) via GraphQL createCommitOnBranch (branch: ${branch}, repo: ${owner}/${repo})`);
try {
@@ -513,6 +614,7 @@ async function pushSignedCommits({ githubClient, owner, repo, branch, baseRef, c
const additions = additionsMap.get(sha) || [];
const deletions = deletionsMap.get(sha) || [];
+ validateSynthesizedFileChanges(additions, deletions, validationConfig);
core.info(`pushSignedCommits: file changes: ${additions.length} addition(s), ${deletions.length} deletion(s)`);
/** @type {any} */
@@ -550,6 +652,9 @@ async function pushSignedCommits({ githubClient, owner, repo, branch, baseRef, c
{ cause: err }
);
}
+ if (err instanceof PushSignedCommitsPolicyViolation) {
+ throw new Error(`pushSignedCommits: refusing unsigned push for branch '${branch}': ${err.message}`, { cause: err });
+ }
if (allowGitPushFallback === false) {
throw new Error(`pushSignedCommits: signed commit push failed for branch '${branch}' and git push fallback is disabled: ${err instanceof Error ? err.message : String(err)}`, { cause: err });
}
diff --git a/setup/js/push_to_pull_request_branch.cjs b/setup/js/push_to_pull_request_branch.cjs
index 85e91339..22e056cc 100644
--- a/setup/js/push_to_pull_request_branch.cjs
+++ b/setup/js/push_to_pull_request_branch.cjs
@@ -22,6 +22,7 @@ const { normalizeCommitSHA } = require("./commit_sha_helpers.cjs");
const { findRepoCheckout } = require("./find_repo_checkout.cjs");
const { getThreatDetectedMarker } = require("./threat_detection_warning.cjs");
const { attachExecutionState } = require("./safe_output_execution_metadata.cjs");
+const { resolveTransportPaths } = require("./resolve_transport_paths.cjs");
/**
* @typedef {import('./types/handler-factory').HandlerFactoryFunction} HandlerFactoryFunction
@@ -183,12 +184,15 @@ async function main(config = {}) {
processedCount++;
- // Determine the patch file path from the message (set by the MCP server handler)
- const patchFilePath = message.patch_path;
+ // Determine the patch and bundle file paths. The MCP server sets these on
+ // the entry it writes, but the validation step strips them as a defense
+ // against agent-forged values. Recover them by re-deriving from `branch`.
+ const transportPaths = resolveTransportPaths(message, defaultTargetRepo);
+ const patchFilePath = transportPaths.patchPath;
core.info(`Patch file path: ${patchFilePath || "(not set)"}`);
// Determine the bundle file path from the message (set when patch-format: bundle is configured)
- const bundleFilePath = message.bundle_path;
+ const bundleFilePath = transportPaths.bundlePath;
if (bundleFilePath) {
core.info(`Bundle file path: ${bundleFilePath}`);
}
@@ -737,10 +741,14 @@ async function main(config = {}) {
core.info(`Applying changes from bundle: ${bundleFilePath}`);
const bundleRef = `refs/bundles/push-${branchName.replace(/[^a-zA-Z0-9-]/g, "-")}`;
try {
- await ensureFullHistoryForBundle(exec, {
- env: { ...process.env, ...gitAuthEnv },
- ...baseGitOpts,
- });
+ await ensureFullHistoryForBundle(
+ exec,
+ {
+ env: { ...process.env, ...gitAuthEnv },
+ ...baseGitOpts,
+ },
+ { baseRef: branchName, bundleFilePath }
+ );
// Fetch from bundle into a temporary ref.
// Use getExecOutput with ignoreReturnCode so we can read the actual stderr from git —
@@ -1072,6 +1080,7 @@ async function main(config = {}) {
signedCommits,
resolvedTemporaryIds,
currentRepo: itemRepo,
+ validationConfig: config,
});
if (pushedSha) {
pushedCommitSha = pushedSha;
diff --git a/setup/js/resolve_transport_paths.cjs b/setup/js/resolve_transport_paths.cjs
new file mode 100644
index 00000000..d09d1d63
--- /dev/null
+++ b/setup/js/resolve_transport_paths.cjs
@@ -0,0 +1,51 @@
+// @ts-check
+
+/** @type {typeof import("fs")} */
+const fs = require("fs");
+const { getPatchPathForBranch, getPatchPathForBranchInRepo } = require("./git_patch_utils.cjs");
+const { getBundlePathForBranch, getBundlePathForBranchInRepo } = require("./generate_git_bundle.cjs");
+
+/**
+ * Derive patch and bundle file paths for a safe-output message from the
+ * validated `branch` (and optional `repo`) fields.
+ *
+ * Paths are always re-derived from `branch` so that even a malicious agent
+ * cannot point the privileged handler at a file outside the canonical
+ * `/tmp/gh-aw/aw-.{patch,bundle}` prefix. Sanitization in
+ * `getPatchPathForBranch` / `getBundlePathForBranch` enforces that prefix.
+ *
+ * @param {Object} message - The safe-output message
+ * @param {string} [defaultTargetRepo] - Default target repo slug used as a fallback
+ * candidate for multi-repo path computation
+ * @returns {{patchPath: string|undefined, bundlePath: string|undefined}}
+ */
+function resolveTransportPaths(message, defaultTargetRepo) {
+ const branch = message.branch;
+ if (!branch) {
+ return { patchPath: undefined, bundlePath: undefined };
+ }
+ /** @type {(string|null)[]} */
+ const repoCandidates = [];
+ if (message.repo) repoCandidates.push(message.repo);
+ if (defaultTargetRepo && defaultTargetRepo !== message.repo) repoCandidates.push(defaultTargetRepo);
+ repoCandidates.push(null);
+ let patchPath;
+ let bundlePath;
+ for (const repo of repoCandidates) {
+ const p = repo ? getPatchPathForBranchInRepo(branch, repo) : getPatchPathForBranch(branch);
+ if (fs.existsSync(p)) {
+ patchPath = p;
+ break;
+ }
+ }
+ for (const repo of repoCandidates) {
+ const p = repo ? getBundlePathForBranchInRepo(branch, repo) : getBundlePathForBranch(branch);
+ if (fs.existsSync(p)) {
+ bundlePath = p;
+ break;
+ }
+ }
+ return { patchPath, bundlePath };
+}
+
+module.exports = { resolveTransportPaths };
diff --git a/setup/js/route_slash_command.cjs b/setup/js/route_slash_command.cjs
index b89c6daf..ab9704f1 100644
--- a/setup/js/route_slash_command.cjs
+++ b/setup/js/route_slash_command.cjs
@@ -83,18 +83,62 @@ function isPRClosedAtStart() {
return false;
}
-function resolveDispatchRef() {
+function normalizeDispatchRef(ref) {
+ if (!ref) {
+ return "";
+ }
+ return ref.startsWith("refs/") ? ref : `refs/heads/${ref}`;
+}
+
+async function resolveIssueBackedPRHeadRef() {
+ const isIssueBackedPullRequest = context.payload?.issue?.pull_request;
+ const pullNumber = context.payload?.issue?.number;
+ if (!isIssueBackedPullRequest || !pullNumber) {
+ return "";
+ }
+
+ try {
+ const response = await github.rest.pulls.get({
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ pull_number: pullNumber,
+ headers: {
+ "X-GitHub-Api-Version": GITHUB_API_VERSION,
+ },
+ });
+ const headRef = response?.data?.head?.ref;
+ if (!headRef) {
+ return "";
+ }
+ return normalizeDispatchRef(headRef);
+ } catch (error) {
+ core.warning(`Failed to resolve PR head ref for #${pullNumber}: ${String(error)}`);
+ return "";
+ }
+}
+
+async function resolveDispatchRef() {
if (process.env.GITHUB_HEAD_REF) {
- return `refs/heads/${process.env.GITHUB_HEAD_REF}`;
+ return normalizeDispatchRef(process.env.GITHUB_HEAD_REF);
+ }
+
+ const payloadHeadRef = context.payload?.pull_request?.head?.ref;
+ if (payloadHeadRef) {
+ return normalizeDispatchRef(payloadHeadRef);
+ }
+
+ const issuePullRequestHeadRef = await resolveIssueBackedPRHeadRef();
+ if (issuePullRequestHeadRef) {
+ return issuePullRequestHeadRef;
}
const fallbackRef = process.env.GITHUB_REF || context.ref;
if (fallbackRef) {
- return fallbackRef;
+ return normalizeDispatchRef(fallbackRef);
}
const defaultBranch = context.payload?.repository?.default_branch || "main";
- return `refs/heads/${defaultBranch}`;
+ return normalizeDispatchRef(defaultBranch);
}
function normalizeReaction(reaction) {
@@ -306,7 +350,7 @@ async function main() {
const identifier = eventIdentifier();
const { buildAwContext } = require("./aw_context.cjs");
- const ref = resolveDispatchRef();
+ const ref = await resolveDispatchRef();
if (isPRClosedAtStart()) {
core.info("Pull request is closed at workflow start; skipping centralized routing.");
return;
diff --git a/setup/js/safe_output_type_validator.cjs b/setup/js/safe_output_type_validator.cjs
index 0196d83d..d9de7359 100644
--- a/setup/js/safe_output_type_validator.cjs
+++ b/setup/js/safe_output_type_validator.cjs
@@ -545,14 +545,6 @@ function validateItem(item, itemType, lineNum, options) {
}
const normalizedItem = { ...item };
- // SECURITY: Strip infrastructure fields that must only be set by the MCP handler,
- // never by the agent. If an agent injects these via NDJSON output, it could bypass
- // file-protection policy (patch_path/bundle_path point to attacker-controlled files)
- // or circumvent size limits (diff_size).
- delete normalizedItem.patch_path;
- delete normalizedItem.bundle_path;
- delete normalizedItem.base_commit;
- delete normalizedItem.diff_size;
const errors = [];
// Run custom validation first if defined
diff --git a/setup/js/safe_outputs_handlers.cjs b/setup/js/safe_outputs_handlers.cjs
index 64a38b06..cf364bc5 100644
--- a/setup/js/safe_outputs_handlers.cjs
+++ b/setup/js/safe_outputs_handlers.cjs
@@ -10,6 +10,7 @@ const { estimateTokens } = require("./estimate_tokens.cjs");
const { writeLargeContentToFile } = require("./write_large_content_to_file.cjs");
const { getCurrentBranch } = require("./get_current_branch.cjs");
const { getBaseBranch } = require("./get_base_branch.cjs");
+const { lookupCheckout } = require("./checkout_manifest.cjs");
const { generateGitPatch } = require("./generate_git_patch.cjs");
const { generateGitBundle } = require("./generate_git_bundle.cjs");
const { hasMergeCommitsInRange, execGitSync } = require("./git_helpers.cjs");
@@ -433,16 +434,26 @@ function createHandlers(server, appendSafeOutput, config = {}) {
}
// Get base branch for the resolved target repository.
- // Prefer explicit safe-output config value when provided, otherwise fall back
- // to dynamic resolution from trigger context/default branch. For side-repo
- // checkouts, prefer repository default-branch resolution from local
- // origin/HEAD metadata before payload/API fallback.
- const baseBranch =
- prConfig.base_branch ||
- (await getBaseBranch(repoParts, {
- preferLocalDefaultBranchMetadata: Boolean(repoCwd),
- cwd: repoCwd || undefined,
- }));
+ // Priority:
+ // 1. Explicit `base-branch` from the workflow config (no I/O, no fetch).
+ // 2. Checkout manifest written by the workflow's setup phase (no network).
+ // 3. Local origin/HEAD metadata + payload/API fallbacks via getBaseBranch.
+ let baseBranch;
+ const configuredBaseBranch = typeof prConfig.base_branch === "string" ? prConfig.base_branch.trim() : "";
+ if (configuredBaseBranch) {
+ baseBranch = configuredBaseBranch;
+ } else {
+ const manifestEntry = lookupCheckout(repoResult.repo);
+ if (manifestEntry && manifestEntry.default_branch) {
+ baseBranch = manifestEntry.default_branch;
+ server.debug(`Using checkout-manifest default_branch for ${repoResult.repo}: ${baseBranch}`);
+ } else {
+ baseBranch = await getBaseBranch(repoParts, {
+ preferLocalDefaultBranchMetadata: Boolean(repoCwd),
+ cwd: repoCwd || undefined,
+ });
+ }
+ }
// Store the resolved base branch in the entry so the apply-time checkout step
// can use it directly instead of inferring from event context.
@@ -637,8 +648,9 @@ function createHandlers(server, appendSafeOutput, config = {}) {
// prettier-ignore
server.debug(`Patch generated successfully: ${patchResult.patchPath} (${patchResult.patchSize} bytes, ${patchResult.patchLines} lines)`);
- // Store the patch path in the entry so consumers know which file to use
- entry.patch_path = patchResult.patchPath;
+ // Patch/bundle paths are not transmitted via the safe-output entry: the
+ // privileged safe_outputs job re-derives them from the (validated) branch name
+ // using resolve_transport_paths.
// Store the base commit SHA so the create_pull_request handler can use it
// directly in the fallback path (the From header in format-patch output
@@ -713,8 +725,9 @@ function createHandlers(server, appendSafeOutput, config = {}) {
}
}
- // Store the bundle path in the entry so consumers know which file to use
- entry.bundle_path = bundleResult.bundlePath;
+ // Bundle path is not transmitted via the safe-output entry: the privileged
+ // safe_outputs job re-derives it from the (validated) branch name using
+ // resolve_transport_paths.
// Prefer the base_commit captured from format-patch generation (used by
// patch-based fallback/apply paths). Only fall back to bundle base commit
@@ -853,12 +866,28 @@ function createHandlers(server, appendSafeOutput, config = {}) {
}
// Get base branch for the resolved target repository.
- // For side-repo checkouts, prefer repository default-branch resolution from
- // local origin/HEAD metadata before payload/API fallback.
- const baseBranch = await getBaseBranch(repoParts, {
- preferLocalDefaultBranchMetadata: Boolean(repoCwd),
- cwd: repoCwd || undefined,
- });
+ // Priority:
+ // 1. Explicit `base-branch` from the workflow config (no I/O, no fetch).
+ // 2. Checkout manifest written by the workflow's setup phase (no network).
+ // 3. Local origin/HEAD metadata in the side-repo checkout (when available).
+ // 4. Payload / GitHub API fallbacks via getBaseBranch.
+ let baseBranch;
+ const configuredBaseBranch = typeof pushConfig.base_branch === "string" ? pushConfig.base_branch.trim() : "";
+ if (configuredBaseBranch) {
+ baseBranch = configuredBaseBranch;
+ server.debug(`Using configured base_branch for push_to_pull_request_branch: ${baseBranch}`);
+ } else {
+ const manifestEntry = lookupCheckout(itemRepo);
+ if (manifestEntry && manifestEntry.default_branch) {
+ baseBranch = manifestEntry.default_branch;
+ server.debug(`Using checkout-manifest default_branch for ${itemRepo}: ${baseBranch}`);
+ } else {
+ baseBranch = await getBaseBranch(repoParts, {
+ preferLocalDefaultBranchMetadata: Boolean(repoCwd),
+ cwd: repoCwd || undefined,
+ });
+ }
+ }
// Store the resolved base branch in the entry so the apply-time checkout step
// can use it directly instead of inferring from event context.
@@ -1034,8 +1063,9 @@ function createHandlers(server, appendSafeOutput, config = {}) {
// prettier-ignore
server.debug(`Patch generated successfully: ${patchResult.patchPath} (${patchResult.patchSize} bytes, ${patchResult.patchLines} lines, diffSize=${patchResult.diffSize ?? "(n/a)"} bytes)`);
- // Store the patch path in the entry so consumers know which file to use
- entry.patch_path = patchResult.patchPath;
+ // Patch/bundle paths are not transmitted via the safe-output entry: the
+ // privileged safe_outputs job re-derives them from the (validated) branch name
+ // using resolve_transport_paths.
// Store the base commit SHA so the push handler can use it directly
if (patchResult.baseCommit) {
@@ -1117,8 +1147,9 @@ function createHandlers(server, appendSafeOutput, config = {}) {
}
}
- // Store the bundle path in the entry so consumers know which file to use
- entry.bundle_path = bundleResult.bundlePath;
+ // Bundle path is not transmitted via the safe-output entry: the privileged
+ // safe_outputs job re-derives it from the (validated) branch name using
+ // resolve_transport_paths.
// Prefer the base_commit captured from format-patch generation (used by
// patch-based fallback/apply paths). Only fall back to bundle base commit
diff --git a/setup/js/safe_outputs_mcp_arguments.cjs b/setup/js/safe_outputs_mcp_arguments.cjs
new file mode 100644
index 00000000..dbdbad55
--- /dev/null
+++ b/setup/js/safe_outputs_mcp_arguments.cjs
@@ -0,0 +1,38 @@
+// @ts-check
+
+const { normalizeTool } = require("./mcp_server_core.cjs");
+
+/**
+ * Unwrap mistakenly nested MCP arguments like { create_discussion: { ... } }.
+ * Applies only when the outer object does not already carry a type.
+ * @param {string} toolName
+ * @param {any} args
+ * @param {{ debug?: (...args: any[]) => void }} [logger]
+ * @returns {any}
+ */
+function normalizeSafeOutputToolArguments(toolName, args, logger) {
+ if (!args || typeof args !== "object" || Array.isArray(args)) {
+ return args;
+ }
+ if (typeof args.type === "string" && args.type.trim()) {
+ return args;
+ }
+
+ const normalizedToolName = normalizeTool(toolName);
+ const candidateKeys = [...new Set([toolName, normalizedToolName, toolName.replace(/_/g, "-"), normalizedToolName.replace(/_/g, "-")])];
+
+ for (const candidateKey of candidateKeys) {
+ const nestedArgs = args[candidateKey];
+ if (nestedArgs && typeof nestedArgs === "object" && !Array.isArray(nestedArgs)) {
+ const outerKeys = Object.keys(args);
+ logger?.debug?.(`Recovered wrapped safe-output tool arguments for '${normalizedToolName}' by unwrapping key '${candidateKey}' from payload keys ${JSON.stringify(outerKeys)}`);
+ return nestedArgs;
+ }
+ }
+
+ return args;
+}
+
+module.exports = {
+ normalizeSafeOutputToolArguments,
+};
diff --git a/setup/js/safe_outputs_mcp_server.cjs b/setup/js/safe_outputs_mcp_server.cjs
index ddfb107d..aca4f49c 100644
--- a/setup/js/safe_outputs_mcp_server.cjs
+++ b/setup/js/safe_outputs_mcp_server.cjs
@@ -19,6 +19,7 @@ const { attachHandlers, registerPredefinedTools, registerDynamicTools } = requir
const { bootstrapSafeOutputsServer, cleanupConfigFile } = require("./safe_outputs_bootstrap.cjs");
const { getErrorMessage } = require("./error_helpers.cjs");
const { ERR_VALIDATION } = require("./error_codes.cjs");
+const { normalizeSafeOutputToolArguments } = require("./safe_outputs_mcp_arguments.cjs");
/**
* Start the safe-outputs MCP server
@@ -32,7 +33,13 @@ function startSafeOutputsServer(options = {}) {
// Create the server instance with optional log directory
const MCP_LOG_DIR = options.logDir || process.env.GH_AW_MCP_LOG_DIR;
- const server = createServer(SERVER_INFO, { logDir: MCP_LOG_DIR });
+ const server = createServer(SERVER_INFO, {
+ logDir: MCP_LOG_DIR,
+ normalizeArguments: (toolName, args) =>
+ normalizeSafeOutputToolArguments(toolName, args, {
+ debug: message => server.debug(message),
+ }),
+ });
// Bootstrap: load configuration and tools using shared logic
const { config: safeOutputsConfig, outputFile, tools: ALL_TOOLS } = bootstrapSafeOutputsServer(server);
diff --git a/setup/js/safe_outputs_mcp_server_http.cjs b/setup/js/safe_outputs_mcp_server_http.cjs
index 6e5643e4..8dcbf418 100644
--- a/setup/js/safe_outputs_mcp_server_http.cjs
+++ b/setup/js/safe_outputs_mcp_server_http.cjs
@@ -45,6 +45,7 @@ const { attachHandlers, registerPredefinedTools, registerDynamicTools } = requir
moduleLogger.debug("Loaded safe_outputs_tools_loader.cjs");
const { getErrorMessage } = require("./error_helpers.cjs");
const { runHttpServer, logStartupError } = require("./mcp_http_server_runner.cjs");
+const { normalizeSafeOutputToolArguments } = require("./safe_outputs_mcp_arguments.cjs");
moduleLogger.debug("All modules loaded successfully");
/**
@@ -81,6 +82,7 @@ function createMCPServer(options = {}) {
capabilities: {
tools: {},
},
+ normalizeArguments: (toolName, args) => normalizeSafeOutputToolArguments(toolName, args, logger),
}
);
diff --git a/setup/js/safe_outputs_tools.json b/setup/js/safe_outputs_tools.json
index f081d594..d4938d34 100644
--- a/setup/js/safe_outputs_tools.json
+++ b/setup/js/safe_outputs_tools.json
@@ -86,7 +86,7 @@
},
{
"name": "create_discussion",
- "description": "Create a GitHub discussion for announcements, Q&A, reports, status updates, or community conversations. Use this for content that benefits from threaded replies, doesn't require task tracking, or serves as documentation. For actionable work items that need assignment and status tracking, use create_issue instead.",
+ "description": "Create a GitHub discussion for announcements, Q&A, reports, status updates, or community conversations. Use this for content that benefits from threaded replies, doesn't require task tracking, or serves as documentation. For actionable work items that need assignment and status tracking, use create_issue instead. Arguments must be flat tool arguments (title, body), not nested under create_discussion.",
"inputSchema": {
"type": "object",
"required": ["title", "body"],
diff --git a/setup/md/agent_failure_comment.md b/setup/md/agent_failure_comment.md
index fa8f838e..148de2e5 100644
--- a/setup/md/agent_failure_comment.md
+++ b/setup/md/agent_failure_comment.md
@@ -1,3 +1,3 @@
Agent job [{run_id}]({run_url}) failed.
-{secret_verification_context}{credential_auth_error_context}{inference_access_error_context}{mcp_policy_error_context}{model_not_supported_error_context}{effective_tokens_rate_limit_error_context}{app_token_minting_failed_context}{lockdown_check_failed_context}{stale_lock_file_failed_context}{daily_effective_workflow_exceeded_context}{assignment_errors_context}{assign_copilot_failure_context}{create_discussion_errors_context}{code_push_failure_context}{repo_memory_validation_context}{push_repo_memory_failure_context}{missing_data_context}{missing_tool_context}{permission_denied_context}{report_incomplete_context}{missing_safe_outputs_context}{engine_failure_context}{timeout_context}{fork_context}
+{secret_verification_context}{credential_auth_error_context}{inference_access_error_context}{mcp_policy_error_context}{model_not_supported_error_context}{effective_tokens_rate_limit_error_context}{ai_credits_rate_limit_error_context}{app_token_minting_failed_context}{lockdown_check_failed_context}{stale_lock_file_failed_context}{daily_effective_workflow_exceeded_context}{assignment_errors_context}{assign_copilot_failure_context}{create_discussion_errors_context}{code_push_failure_context}{repo_memory_validation_context}{push_repo_memory_failure_context}{missing_data_context}{missing_tool_context}{permission_denied_context}{tool_denials_exceeded_context}{report_incomplete_context}{missing_safe_outputs_context}{engine_failure_context}{timeout_context}{fork_context}
diff --git a/setup/md/agent_failure_issue.md b/setup/md/agent_failure_issue.md
index ae36d126..0e990e75 100644
--- a/setup/md/agent_failure_issue.md
+++ b/setup/md/agent_failure_issue.md
@@ -4,7 +4,7 @@
**Branch:** {branch}
**Run:** {run_url}{pull_request_info}
-{secret_verification_context}{credential_auth_error_context}{inference_access_error_context}{mcp_policy_error_context}{model_not_supported_error_context}{effective_tokens_rate_limit_error_context}{app_token_minting_failed_context}{lockdown_check_failed_context}{stale_lock_file_failed_context}{daily_effective_workflow_exceeded_context}{assignment_errors_context}{assign_copilot_failure_context}{create_discussion_errors_context}{code_push_failure_context}{repo_memory_validation_context}{push_repo_memory_failure_context}{missing_data_context}{missing_tool_context}{permission_denied_context}{report_incomplete_context}{missing_safe_outputs_context}{engine_failure_context}{timeout_context}{fork_context}
+{secret_verification_context}{credential_auth_error_context}{inference_access_error_context}{mcp_policy_error_context}{model_not_supported_error_context}{effective_tokens_rate_limit_error_context}{ai_credits_rate_limit_error_context}{app_token_minting_failed_context}{lockdown_check_failed_context}{stale_lock_file_failed_context}{daily_effective_workflow_exceeded_context}{assignment_errors_context}{assign_copilot_failure_context}{create_discussion_errors_context}{code_push_failure_context}{repo_memory_validation_context}{push_repo_memory_failure_context}{missing_data_context}{missing_tool_context}{permission_denied_context}{tool_denials_exceeded_context}{report_incomplete_context}{missing_safe_outputs_context}{engine_failure_context}{timeout_context}{fork_context}
### Action Required
diff --git a/setup/md/ai_credits_rate_limit_error.md b/setup/md/ai_credits_rate_limit_error.md
new file mode 100644
index 00000000..a56e80d9
--- /dev/null
+++ b/setup/md/ai_credits_rate_limit_error.md
@@ -0,0 +1,10 @@
+
+⚠️ AI Credits Budget Exceeded
+
+The workflow hit the configured `max-ai-credits` budget and the firewall rejected additional model requests.{usage_line}{budget_line}{run_line}
+
+To reduce recurrence:
+- Increase `max-ai-credits` for this workflow when appropriate.
+- Reduce unnecessary model/tool calls in the prompt.
+- Split large tasks across smaller runs.
+
diff --git a/setup/md/daily_cap_rollup_comment.md b/setup/md/daily_cap_rollup_comment.md
new file mode 100644
index 00000000..f47f674c
--- /dev/null
+++ b/setup/md/daily_cap_rollup_comment.md
@@ -0,0 +1,9 @@
+## Failure suppressed by daily cap
+
+**Workflow:** {workflow_name}
+**Run:** [{run_url}]({run_url})
+**Capped categories:** {summary}
+
+A new failure issue was not created because the per-category cap of **{cap} issues per {window_hours}h** has been reached.
+
+Consider investigating why this workflow is failing repeatedly.
diff --git a/setup/md/daily_cap_rollup_issue.md b/setup/md/daily_cap_rollup_issue.md
new file mode 100644
index 00000000..a046df62
--- /dev/null
+++ b/setup/md/daily_cap_rollup_issue.md
@@ -0,0 +1,13 @@
+This issue tracks agentic workflow failures that were suppressed because the per-category daily issue cap of **{cap} issues per {window_hours} hours** was reached.
+
+When a workflow repeatedly fails with the same category, new issues are no longer created once the cap is reached. Instead, a comment is added here to record each suppressed occurrence.
+
+### What to Do
+
+1. Review the comments below to identify the workflow(s) failing at a high rate.
+2. Investigate and fix the underlying cause to reduce the failure rate.
+3. Once the failure rate returns to normal, this issue can be closed.
+
+---
+
+> This issue is automatically managed by GitHub Agentic Workflows. Do not close this issue manually.
diff --git a/setup/md/detection_runs_comment.md b/setup/md/detection_runs_comment.md
index d4ddb564..a8ac9baa 100644
--- a/setup/md/detection_runs_comment.md
+++ b/setup/md/detection_runs_comment.md
@@ -2,4 +2,4 @@
**Conclusion:** {conclusion} | **Reason:** {reason}
-> Generated from [{workflow_name}]({run_url}){effective_tokens_suffix}
+> Generated from [{workflow_name}]({run_url})
diff --git a/setup/md/effective_tokens_rate_limit_error.md b/setup/md/effective_tokens_rate_limit_error.md
index c893e9e8..2c667224 100644
--- a/setup/md/effective_tokens_rate_limit_error.md
+++ b/setup/md/effective_tokens_rate_limit_error.md
@@ -1,12 +1,18 @@
-**⚠️ Effective Token Budget Exhausted**: The run failed due to effective-token budget/rate-limit enforcement in the API proxy.
+**⚠️ AI Credits Budget Guidance**: The run hit a legacy effective-token rate-limit signal from the API proxy. gh-aw now uses AI Credits (AIC) as the primary cost metric, so migrate per-run budgeting to `max-ai-credits`.
Why this happened and how to optimize
-- Learn about [effective tokens]({et_spec_link}).
+- Learn about [AI Credits]({ai_credits_spec_link}).
{usage_line}{budget_line}{run_line}
-You can tune this limit with `max-effective-tokens` in workflow frontmatter.
+- `max-effective-tokens` is deprecated; migrate to `max-ai-credits` by running `gh aw fix --write`, or update manually (1 AIC = 10,000 ET):
+ ```yaml
+ # before
+ max-effective-tokens: 5000000
+ # after
+ max-ai-credits: 500
+ ```
{et_table_section}
-- To optimize this workflow, follow the [token optimization instructions]({token_opt_link}).
+- To budget and optimize this workflow, follow the [cost management guidance]({cost_management_link}).
diff --git a/setup/md/noop_comment.md b/setup/md/noop_comment.md
index 43b5c77b..e003b5cd 100644
--- a/setup/md/noop_comment.md
+++ b/setup/md/noop_comment.md
@@ -2,4 +2,4 @@
{message}
-> Generated from [{workflow_name}]({run_url}){effective_tokens_suffix}
+> Generated from [{workflow_name}]({run_url})
diff --git a/setup/md/tool_denials_exceeded_context.md b/setup/md/tool_denials_exceeded_context.md
new file mode 100644
index 00000000..a505c1d3
--- /dev/null
+++ b/setup/md/tool_denials_exceeded_context.md
@@ -0,0 +1,23 @@
+
+**⚠️ Excessive Tool Denials**: The Copilot SDK hit the max tool denial guardrail and stopped the session early (`{denial_count}/{threshold}`).
+
+**Last denied request:**
+{reason}
+
+This is a structured guardrail event (`guard.tool_denials_exceeded`) captured in `events.jsonl`.
+
+
+How to fix this
+
+The prompt attempted actions outside the workflow's allowed tools.
+
+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}
+
+Please update the workflow so the prompt only uses tools permitted by the workflow tool policy.
+```
+
+
diff --git a/setup/setup.sh b/setup/setup.sh
index 26991911..3ec8fa56 100755
--- a/setup/setup.sh
+++ b/setup/setup.sh
@@ -266,6 +266,7 @@ create_dir "${SAFE_OUTPUTS_DEST}"
SAFE_OUTPUTS_FILES=(
"safe_outputs_mcp_server.cjs"
"safe_outputs_mcp_server_http.cjs"
+ "safe_outputs_mcp_arguments.cjs"
"safe_outputs_bootstrap.cjs"
"safe_outputs_tools_loader.cjs"
"safe_outputs_config.cjs"
@@ -310,6 +311,7 @@ SAFE_OUTPUTS_FILES=(
"error_codes.cjs"
"constants.cjs"
"git_helpers.cjs"
+ "checkout_manifest.cjs"
"github_api_helpers.cjs"
"find_repo_checkout.cjs"
"mcp_enhanced_errors.cjs"
diff --git a/setup/sh/start_safe_outputs_server.sh b/setup/sh/start_safe_outputs_server.sh
index bb1aafd1..e5c9f4b0 100755
--- a/setup/sh/start_safe_outputs_server.sh
+++ b/setup/sh/start_safe_outputs_server.sh
@@ -32,6 +32,7 @@ fi
# These files are required by safe_outputs_mcp_server_http.cjs and its dependencies
REQUIRED_DEPS=(
"safe_outputs_mcp_server_http.cjs"
+ "safe_outputs_mcp_arguments.cjs"
"mcp_http_transport.cjs"
"mcp_logger.cjs"
"safe_outputs_bootstrap.cjs"
@@ -44,6 +45,7 @@ REQUIRED_DEPS=(
"safe_outputs_tools_loader.cjs"
"safe_outputs_config.cjs"
"safe_outputs_config_redact.cjs"
+ "checkout_manifest.cjs"
)
MISSING_FILES=()