On native Windows, external CLI commands are always wrapped by PowerShell, making permission rules hard to match reliably

Open 💬 2 comments Opened Mar 20, 2026 by leeeezx
💡 Likely answer: A maintainer (github-actions[bot], contributor) responded on this thread — see the highlighted reply below.

Problem

When using Codex CLI on native Windows, external CLI commands appear to be wrapped and executed through PowerShell rather than being treated as direct command/argv invocations.

For example, I would expect a command like this to be recognized in a direct form:

["agent-browser", "open", "https://example.com"]

But in practice, execution appears closer to:

pwsh.exe -Command "agent-browser open https://example.com ..."

This makes permission rules, prefix matching, and command recognition much less reliable on Windows, especially for tools like agent-browser that are already standalone CLIs.

---

Current issue

  1. External CLI commands on native Windows are generally wrapped inside PowerShell.
  2. The rules system often sees pwsh.exe -Command "..." instead of the original external command and its arguments.
  3. This makes it difficult to configure tools like agent-browser as reliably default-allowed via prefix rules.
  4. Matching behavior may vary depending on arguments, URLs, or chained command forms, which reduces predictability.

---

Expected behavior

I would like Codex to provide more stable and predictable execution and rule-matching behavior for external CLI commands on native Windows.

From a user perspective, the expectation is:

  1. External CLI commands should be recognized as close to their original command form as possible;
  2. Permission rules should be able to reliably match the actual tool prefix users intend to allow;
  3. Standalone CLIs such as agent-browser should be easier to configure as default-allowed without being affected by PowerShell wrapping behavior.

---

Codex information

  • Codex CLI version: 0.116.0
  • Platform: native Windows
  • Shell: PowerShell / pwsh
  • Approval mode: on-request
  • Sandbox mode: workspace-write

---

Additional context

This mainly affects external CLI tool execution and permission rule matching on native Windows. The problem becomes especially visible when trying to default-allow specific command-line tools.

View original on GitHub ↗

2 Comments

github-actions[bot] contributor · 5 months ago

Potential duplicates detected. Please review them and close your issue if it is a duplicate.

  • #13502
  • #15292
  • #15214

Powered by Codex Action

PranavMishra28 · 25 days ago

There look to be two independent mechanisms here, which may be why the behaviour seems to vary by argument. All references are main @ bb5054fe.

The wrap itself is unconditional. shell_command takes a script string and derive_exec_args always produces [pwsh, -NoProfile, -Command, <script>] (core/src/shell.rs:32-41, via handlers/shell/shell_command.rs:84-86), and that argv is what the approval layer receives verbatim (handlers/shell.rs:175-190).

Rule matching then tries to unwrap and fails open. commands_for_exec_policy (core/src/exec_policy.rs:845-883) attempts the PowerShell lowering at :856-867, and if it returns None it falls through to :878-882 and evaluates the raw wrapper argv ["pwsh.exe","-NoProfile","-Command","<whole script>"], which no user prefix_rule can match. With command_origin == Generic the safelist check at :743-749 also cannot fire, so you get prompted.

The lowering rejects more than it looks like it would. try_parse_powershell_ast_commands maps both Unsupported and Failed to None (command_safety/powershell_parser.rs:37-45), and the AST walker in powershell_parser.ps1 returns unsupported for: any parse error (:41-43); param/begin/process/clean blocks, using, traps (:47-58); --% (:63-67); any top-level statement that is not a pipeline, which excludes assignments, if, foreach, try/catch and function (:234-248); any redirection or invocation operator, so & "C:\path\tool.exe" args is out (:160-169); and any non-literal argument, so $env:FOO, "$dir\file", $(...) and @args are out (:115-152, whose own comment says dynamic forms make the whole request unsupported). It is all-or-nothing per invocation, so one dynamic argument anywhere makes every segment fall back to the raw wrapper.

There is a second gate that also fails closed: extract_powershell_command rejects any flag before -Command outside a four-item allowlist (shell-command/src/powershell.rs:9, :60-63), so -NonInteractive, -ExecutionPolicy Bypass, -NoExit, -File and -EncodedCommand all prevent unwrapping.

Separately, and I think this is the part that best explains "varies depending on arguments, URLs": the approval cache never lowers PowerShell at all. canonicalize_command_for_approval (core/src/command_canonicalization.rs:14-38) has a parse_shell_lc_plain_commands branch for bash at :15-19 but no PowerShell equivalent, and the PowerShell path at :30-34 returns [<prefix>, <raw script string>]. So on Windows the approval key is the exact script text, and every distinct URL is a new cache key with no relation to the tool name. "Approve for this session" effectively means "approve this exact string".

Verified from source: the unconditional wrap, the Generic fallback, the rejection list, the flag allowlist, the missing canonicalization branch, and the safelist consequence.

Hypothesis, since I do not have a Windows host to run these: that the specific agent-browser open <url> failures are parse errors rather than dynamic-argument rejections. An unquoted & in a query string is a PowerShell syntax error, which hits status = 'parse_errors' at powershell_parser.ps1:41-43, while the same URL quoted should lower fine. Also worth ruling out that the parser child process is alive at all: PowershellParserProcess::spawn (powershell_parser.rs:115-127) keeps a long-lived -EncodedCommand child, and if that spawn is blocked by AV or constrained language mode then parse_with_cached_process returns Failed and every command falls back, which would make this fully reproducible rather than argument-dependent.

If someone on Windows can run these with prefix_rule(pattern=["agent-browser"], decision="allow") installed, it should separate the two:

1. agent-browser open https://example.com                  (bareword)
2. agent-browser open "https://example.com/x?a=1&b=2"      (quoted &)
3. agent-browser open https://example.com/x?a=1&b=2        (unquoted &, expect parse error)
4. & "C:\tools\agent-browser.exe" open https://example.com  (call operator)
5. $u = "https://example.com"; agent-browser open $u       (assignment + variable)

Predicted: 1 and 2 match the rule, 3 4 and 5 do not. If 1 also fails, check Get-Process pwsh, powershell while Codex is running, because a dead parser child would explain all of it at once. For the approval-cache half: approve agent-browser open https://a.com for the session, then run it with https://b.com. Being prompted again points at command_canonicalization.rs.

On fixes, the cheap pair is adding the PowerShell branch to canonicalize_command_for_approval, which fixes the per-URL churn but not the prefix-rule complaint, and widening POWERSHELL_FLAGS, being careful that the loop at powershell.rs:57-69 advances one index at a time so value-taking flags like -ExecutionPolicy Bypass need handling. Widening the AST lowering is what actually fixes the headline complaint, but it needs care: the same lowered argv feeds is_safe_powershell_words and dangerous_powershell_words_match, and bash draws that line deliberately, its literal parser being documented as "must not be used to prove that a command is safe". That probably wants a two-tier split, a permissive parse for matching and the conservative one for safety, rather than one widened parser.