Skip to content

Commit 2059a22

Browse files
Copilotpelikhan
andauthored
Add sandbox shell-expansion guard detection + safer multi-line body prompt guidance
Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com>
1 parent 0aebbef commit 2059a22

3 files changed

Lines changed: 132 additions & 3 deletions

File tree

actions/setup/js/detect_agent_errors.cjs

Lines changed: 42 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,14 @@
3434
* Detected from the agent stdio log (text pattern) and the AWF firewall audit
3535
* JSONL log (`unknown_model_ai_credits` event type). Both sources are checked
3636
* and their results merged.
37+
* - shell_expansion_guard_rejected: The sandbox's shell command-injection guard
38+
* rejected a shell command for containing (or appearing to contain) bash
39+
* expansion patterns (command substitution, indirect expansion, parameter
40+
* transformation, etc.), e.g. "...could enable arbitrary code execution.
41+
* Please rewrite the command without these expansion patterns." This can
42+
* misfire on benign multi-line `printf`/`safeoutputs` CLI invocations; agents
43+
* should switch to the `jq -Rs` file-piping pattern instead of retrying the
44+
* same command verbatim.
3745
* This replaces the individual bash scripts (detect_inference_access_error.sh,
3846
* detect_mcp_policy_error.sh) with a single JavaScript step.
3947
*
@@ -173,6 +181,16 @@ const INVOCATION_CAP_EXCEEDED_PATTERN = buildCombinedPattern(MAX_RUNS_EXCEEDED_P
173181
// parseMaxCacheMissesExceededFromEventLog().
174182
const MAX_CACHE_MISSES_EXCEEDED_PATTERN = /(?:\bmax_cache_misses_exceeded\b|\bmaximum\s+consecutive\s+cache\s+misses\s+exceeded\b)/i;
175183

184+
// Pattern: the sandbox's shell command-injection guard rejected a shell command believed to
185+
// contain dangerous bash expansion patterns (command substitution, indirect expansion, parameter
186+
// transformation, backtick substitution, etc.). Observed message form:
187+
// "...indirect expansion, or nested command substitution) that could enable arbitrary code
188+
// execution. Please rewrite the command without these expansion patterns."
189+
// This guard can misfire on benign multi-line printf/safeoutputs CLI invocations. Retrying the
190+
// identical command is pointless — it will be rejected again — so this is surfaced as a distinct,
191+
// actionable diagnostic instead of a generic shell failure.
192+
const SHELL_EXPANSION_GUARD_REJECTED_PATTERN = /could enable arbitrary code execution\b[^\n]{0,160}\brewrite the command without these expansion patterns\b/i;
193+
176194
/**
177195
* Determines if the collected output contains the observed Copilot/CAPI quota exhaustion error.
178196
* @param {string} output - Collected stdout+stderr from the process
@@ -205,6 +223,18 @@ function isMaxCacheMissesExceededError(output) {
205223
return MAX_CACHE_MISSES_EXCEEDED_PATTERN.test(output);
206224
}
207225

226+
/**
227+
* Determines if the collected output shows the sandbox's shell command-injection guard
228+
* rejected a command for containing (or appearing to contain) dangerous bash expansion
229+
* patterns. Retrying the same command verbatim will not succeed; the agent should switch
230+
* to the `jq -Rs` file-piping pattern for multi-line safeoutputs CLI bodies instead.
231+
* @param {string} output - Collected stdout+stderr from the process
232+
* @returns {boolean}
233+
*/
234+
function isShellExpansionGuardRejectedError(output) {
235+
return SHELL_EXPANSION_GUARD_REJECTED_PATTERN.test(output);
236+
}
237+
208238
/**
209239
* Normalize model names to a single safe line for GitHub Actions outputs and issue titles.
210240
* @param {string} value
@@ -227,7 +257,7 @@ function extractMissingModelPricingModelName(logContent) {
227257
/**
228258
* Detect known error patterns in a log string and return detection results.
229259
* @param {string} logContent - Contents of the agent stdio log
230-
* @returns {{ inferenceAccessError: boolean, mcpPolicyError: boolean, agenticEngineTimeout: boolean, modelNotSupportedError: boolean, http400ResponseError: boolean, capiQuotaExceededError: boolean, invocationCapExceeded: boolean, maxCacheMissesExceeded: boolean, missingModelPricingError: boolean, missingModelPricingModelName: string }}
260+
* @returns {{ inferenceAccessError: boolean, mcpPolicyError: boolean, agenticEngineTimeout: boolean, modelNotSupportedError: boolean, http400ResponseError: boolean, capiQuotaExceededError: boolean, invocationCapExceeded: boolean, maxCacheMissesExceeded: boolean, missingModelPricingError: boolean, missingModelPricingModelName: string, shellExpansionGuardRejected: boolean }}
231261
*/
232262
function detectErrors(logContent) {
233263
const missingModelPricingModelName = extractMissingModelPricingModelName(logContent);
@@ -242,12 +272,13 @@ function detectErrors(logContent) {
242272
maxCacheMissesExceeded: isMaxCacheMissesExceededError(logContent),
243273
missingModelPricingError: missingModelPricingModelName !== "",
244274
missingModelPricingModelName,
275+
shellExpansionGuardRejected: isShellExpansionGuardRejectedError(logContent),
245276
};
246277
}
247278

248279
/**
249280
* Build GitHub Actions output lines from detection results.
250-
* @param {{ inferenceAccessError: boolean, mcpPolicyError: boolean, agenticEngineTimeout: boolean, modelNotSupportedError: boolean, http400ResponseError: boolean, capiQuotaExceededError: boolean, invocationCapExceeded: boolean, maxCacheMissesExceeded: boolean, missingModelPricingError: boolean, missingModelPricingModelName: string }} results
281+
* @param {{ inferenceAccessError: boolean, mcpPolicyError: boolean, agenticEngineTimeout: boolean, modelNotSupportedError: boolean, http400ResponseError: boolean, capiQuotaExceededError: boolean, invocationCapExceeded: boolean, maxCacheMissesExceeded: boolean, missingModelPricingError: boolean, missingModelPricingModelName: string, shellExpansionGuardRejected: boolean }} results
251282
* @returns {string[]}
252283
*/
253284
function buildOutputLines(results) {
@@ -263,12 +294,13 @@ function buildOutputLines(results) {
263294
`max_cache_misses_exceeded=${results.maxCacheMissesExceeded}`,
264295
`missing_model_pricing_error=${results.missingModelPricingError}`,
265296
`missing_model_pricing_model_name=${results.missingModelPricingModelName}`,
297+
`shell_expansion_guard_rejected=${results.shellExpansionGuardRejected}`,
266298
];
267299
}
268300

269301
/**
270302
* Write GitHub Actions outputs to $GITHUB_OUTPUT.
271-
* @param {{ inferenceAccessError: boolean, mcpPolicyError: boolean, agenticEngineTimeout: boolean, modelNotSupportedError: boolean, http400ResponseError: boolean, capiQuotaExceededError: boolean, invocationCapExceeded: boolean, maxCacheMissesExceeded: boolean, missingModelPricingError: boolean, missingModelPricingModelName: string }} results
303+
* @param {{ inferenceAccessError: boolean, mcpPolicyError: boolean, agenticEngineTimeout: boolean, modelNotSupportedError: boolean, http400ResponseError: boolean, capiQuotaExceededError: boolean, invocationCapExceeded: boolean, maxCacheMissesExceeded: boolean, missingModelPricingError: boolean, missingModelPricingModelName: string, shellExpansionGuardRejected: boolean }} results
272304
*/
273305
function writeOutputs(results) {
274306
const outputFile = process.env.GITHUB_OUTPUT;
@@ -351,6 +383,11 @@ function main() {
351383
if (results.missingModelPricingError && !auditMissingPricing) {
352384
process.stderr.write(`[detect-agent-errors] Detected missing model pricing: model "${results.missingModelPricingModelName}" has no AI credits pricing configured\n`);
353385
}
386+
if (results.shellExpansionGuardRejected) {
387+
process.stderr.write(
388+
"[detect-agent-errors] Detected sandbox shell expansion guard rejection: a shell command was rejected for dangerous bash expansion patterns; use the jq -Rs file-piping pattern for multi-line safeoutputs CLI bodies instead of retrying\n"
389+
);
390+
}
354391

355392
writeOutputs(results);
356393
}
@@ -378,5 +415,7 @@ module.exports = {
378415
INVOCATION_CAP_EXCEEDED_PATTERN,
379416
MAX_CACHE_MISSES_EXCEEDED_PATTERN,
380417
MISSING_MODEL_PRICING_PATTERN,
418+
SHELL_EXPANSION_GUARD_REJECTED_PATTERN,
419+
isShellExpansionGuardRejectedError,
381420
buildOutputLines,
382421
};

actions/setup/js/detect_agent_errors.test.cjs

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@ const {
1717
INVOCATION_CAP_EXCEEDED_PATTERN,
1818
MAX_CACHE_MISSES_EXCEEDED_PATTERN,
1919
MISSING_MODEL_PRICING_PATTERN,
20+
SHELL_EXPANSION_GUARD_REJECTED_PATTERN,
21+
isShellExpansionGuardRejectedError,
2022
extractMissingModelPricingModelName,
2123
buildOutputLines,
2224
} = require("./detect_agent_errors.cjs");
@@ -326,6 +328,7 @@ describe("detect_agent_errors.cjs", () => {
326328
expect(result.maxCacheMissesExceeded).toBe(false);
327329
expect(result.missingModelPricingError).toBe(false);
328330
expect(result.missingModelPricingModelName).toBe("");
331+
expect(result.shellExpansionGuardRejected).toBe(false);
329332
});
330333

331334
it("detects inference access error only", () => {
@@ -416,6 +419,24 @@ describe("detect_agent_errors.cjs", () => {
416419
expect(result.invocationCapExceeded).toBe(true);
417420
});
418421

422+
it("detects shell expansion guard rejection only (issue github/gh-aw#52254 payload shape)", () => {
423+
const log = [
424+
"[copilot-harness] attempt 1: shell(safeoutputs create_discussion --title 'MCP toolset unavailable' --body \"...\\n...\")",
425+
"Command rejected: shell command contains dangerous patterns (command substitution, indirect expansion, or nested command substitution) that could enable arbitrary code execution. Please rewrite the command without these expansion patterns.",
426+
"[copilot-harness] attempt 2: retrying identical command",
427+
"Command rejected: shell command contains dangerous patterns (command substitution, indirect expansion, or nested command substitution) that could enable arbitrary code execution. Please rewrite the command without these expansion patterns.",
428+
"##[error]The action 'Execute GitHub Copilot CLI' has timed out after 5 minutes.",
429+
].join("\n");
430+
const result = detectErrors(log);
431+
expect(result.inferenceAccessError).toBe(false);
432+
expect(result.mcpPolicyError).toBe(false);
433+
expect(result.modelNotSupportedError).toBe(false);
434+
expect(result.http400ResponseError).toBe(false);
435+
expect(result.capiQuotaExceededError).toBe(false);
436+
expect(result.invocationCapExceeded).toBe(false);
437+
expect(result.shellExpansionGuardRejected).toBe(true);
438+
});
439+
419440
it("detects both capi quota and invocation-cap flags when both signatures are present", () => {
420441
const result = detectErrors("CAPIError: Too Many Requests\nCAPIError: 429 Maximum LLM invocations exceeded (25/25)");
421442
expect(result.capiQuotaExceededError).toBe(true);
@@ -621,6 +642,46 @@ commentary" has no AI credits pricing`;
621642
});
622643
});
623644

645+
describe("SHELL_EXPANSION_GUARD_REJECTED_PATTERN / isShellExpansionGuardRejectedError", () => {
646+
// Exact payload shape from the issue report (github/gh-aw#52254): the sandbox's shell
647+
// command-injection guard rejected a benign multi-line `printf` call to `safeoutputs
648+
// create_discussion` for containing bash expansion patterns.
649+
const ISSUE_REJECTION_MESSAGE =
650+
"Command rejected: shell command contains dangerous patterns (command substitution, " +
651+
"indirect expansion, or nested command substitution) that could enable arbitrary code " +
652+
"execution. Please rewrite the command without these expansion patterns.";
653+
654+
it("matches the exact rejection message from the issue report", () => {
655+
expect(SHELL_EXPANSION_GUARD_REJECTED_PATTERN.test(ISSUE_REJECTION_MESSAGE)).toBe(true);
656+
expect(isShellExpansionGuardRejectedError(ISSUE_REJECTION_MESSAGE)).toBe(true);
657+
});
658+
659+
it("matches when embedded in larger multi-line log output", () => {
660+
const log = [
661+
"[copilot-harness] attempt 1: invoking shell(safeoutputs create_discussion --title ... --body ...)",
662+
ISSUE_REJECTION_MESSAGE,
663+
"[copilot-harness] attempt 2: retrying identical command",
664+
ISSUE_REJECTION_MESSAGE,
665+
"##[error]The action 'Execute GitHub Copilot CLI' has timed out after 5 minutes.",
666+
].join("\n");
667+
expect(isShellExpansionGuardRejectedError(log)).toBe(true);
668+
});
669+
670+
it("is case-insensitive", () => {
671+
expect(SHELL_EXPANSION_GUARD_REJECTED_PATTERN.test(ISSUE_REJECTION_MESSAGE.toUpperCase())).toBe(true);
672+
});
673+
674+
it("does not match unrelated shell errors", () => {
675+
expect(isShellExpansionGuardRejectedError("bash: safeoutputs: command not found")).toBe(false);
676+
expect(isShellExpansionGuardRejectedError("permission denied by workflow tool permissions")).toBe(false);
677+
expect(isShellExpansionGuardRejectedError("")).toBe(false);
678+
});
679+
680+
it("does not match arbitrary code execution mentions without the rewrite guidance", () => {
681+
expect(isShellExpansionGuardRejectedError("This could enable arbitrary code execution if left unchecked.")).toBe(false);
682+
});
683+
});
684+
624685
describe("WATCHDOG_SIGTERM_PATTERN", () => {
625686
it("matches a process closed line with SIGTERM and watchdogFired=true", () => {
626687
const log = "[copilot-harness] attempt 1: process closed exitCode=1 signal=SIGTERM duration=12m 38s hasOutput=true watchdogFired=true";
@@ -799,5 +860,23 @@ commentary" has no AI credits pricing`;
799860

800861
expect(lines).toContain("max_cache_misses_exceeded=false");
801862
});
863+
864+
it("emits shell_expansion_guard_rejected=true when detected", () => {
865+
const lines = buildOutputLines({
866+
inferenceAccessError: false,
867+
mcpPolicyError: false,
868+
agenticEngineTimeout: false,
869+
modelNotSupportedError: false,
870+
http400ResponseError: false,
871+
capiQuotaExceededError: false,
872+
invocationCapExceeded: false,
873+
maxCacheMissesExceeded: false,
874+
missingModelPricingError: false,
875+
missingModelPricingModelName: "",
876+
shellExpansionGuardRejected: true,
877+
});
878+
879+
expect(lines).toContain("shell_expansion_guard_rejected=true");
880+
});
802881
});
803882
});

actions/setup/md/mcp_cli_tools_prompt.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,17 @@ printf '{"item_number":42,"body":"### Title\n\nBody."}' | safeoutputs add_commen
1313
# or write to a file: safeoutputs create_pull_request . < /tmp/payload.json
1414
```
1515

16+
**Multi-line or long `body` content:** do NOT build the JSON payload with `printf`/`echo` embedding raw newlines or many escaped characters directly in the command line — the sandbox's shell command-injection guard may reject long or complex quoted arguments (reporting "expansion patterns"/"command substitution" even though none are present) and retrying the identical command will fail again. Instead, write the content to a temp file with a heredoc, then use `jq -Rs` to inject it as the `body` field:
17+
```bash
18+
cat <<'EOF' > /tmp/gh-aw/body.md
19+
### Title
20+
21+
Multi-line body content goes here.
22+
EOF
23+
jq -Rs '{title: "My title", body: .}' /tmp/gh-aw/body.md | safeoutputs create_discussion .
24+
```
25+
If a shell command is rejected for containing expansion patterns, do not retry the same command — switch to the heredoc + `jq -Rs` pattern above.
26+
1627
To inject an entire local file as the `body` field without re-embedding its content in the model context, use `jq -Rs`:
1728
```bash
1829
jq -Rs --arg discussion_number "$DISCUSSION_NUMBER" \

0 commit comments

Comments
 (0)