Managed requirements.toml prompt/forbidden rules do not fire for non-trivial or nested shell scripts; agent's default zsh -c style bypasses an admin rm rule
Nobody has claimed this yet.
- Dominant language
- Rust
- Stars
- 125k
- Forks
- 19.4k
- PR merge metrics
- PR metrics pending
Description
What version of the Codex App are you using (From “About Codex” dialog)?
ChatGPT for macOS 26.908.40834 (8881), bundled codex-cli 0.154.0-alpha.6.2
What subscription do you have?
ChatGPT Business (workspace admin; managed configuration via MDM)
What platform is your computer?
Darwin 25.6.0 arm64 arm (macOS 26.6.2)
What issue are you seeing?
Setup
- Standard (non-admin) macOS user, device managed by MDM.
- Managed configuration delivered as
com.openai.codex : requirements_toml_base64via configuration profile. - Permission profile: custom profile extending
:workspace, workspace roots writable,network.enabled = true. - Approvals:
allowed_approval_policies = ["on-request"],allowed_approvals_reviewers = ["auto_review"]with aguardian_policy_configthat denies deletes under/Volumesand~/Library/CloudStorage.
Admin rule in requirements.toml:
[rules]
prefix_rules = [
{ pattern = [{ any_of = ["rm", "rmdir", "trash", "srm", "shred", "unlink"] }], decision = "prompt",
justification = "Deleting files is reviewed. Files on network shares and in OneDrive are never deleted by the agent." },
]
Intent: every delete goes to the reviewer.
What happened (from the local rollouts in ~/.codex/sessions)
The app wraps every exec_command in /bin/zsh -c '<cmd>'. Three cases, same profile, same SMB share subfolder added as a workspace root:
A. Plain-word delete -> rule fired, reviewer denied (correct). The agent issued rm -- /Volumes/<share>/ai/a.ai /Volumes/<share>/ai/b.ai. The approval request read "/bin/zsh -c 'rm -- ...' requires approval: Deleting files is reviewed..." (our justification) and the auto_review guardian returned {"risk_level":"critical","outcome":"deny"}. So the wrapper IS unwrapped and split when the script is plain words, as documented.
B. Script with set -e, variables, glob qualifiers and if -> no match, 16 files permanently deleted, no prompt, reviewer never consulted.
/bin/zsh -c 'set -e; target=/Volumes/<share>/pdf/draft; files=("$target"/*(ND.)); if (( ${#files} != 6 )); then ...; exit 1; fi; /bin/rm -- "${files[@]}"; ...'
/bin/zsh -c 'set -e; target=/Volumes/<share>/pdf; pdfs=("$target"/*.[pP][dD][fF](N.)); ...; /bin/rm -- "${pdfs[@]}"; /bin/rmdir "$target/draft"; ...'
Per the docs this script is "treated as a single command without splitting", so the rm inside is invisible to the rule and the delete runs in the sandbox (the root is writable). This is the agent's default command style for anything non-trivial. The only command that reached the reviewer in this thread was a later bare retry rmdir /Volumes/<share>/pdf/draft with require_escalated, which the reviewer denied.
C. Nested shell -> no match, 2 files deleted, no prompt. In a fresh thread the user asked for the delete "via bash -lc". The agent issued bash -lc 'rm -- /Volumes/<share>/ai/a.ai /Volumes/<share>/ai/b.ai && ls -la /Volumes/<share>/ai/', which the app wrapped as /bin/zsh -c "bash -lc '...'". The outer wrapper was split, yielding one command whose argv[0] is bash; the inner bash -lc script, a plain-word && chain that would have been split at top level, was not unwrapped. No rule matched, the files were deleted inside the sandbox.
So a prompt/forbidden rule on rm is defeated by (B) any non-trivial script, which is the agent's default, and (C) one level of shell nesting, which a user can request in plain English.
Why (as far as the docs go)
- Rules doc: a
bash -lc/zsh -cscript is split into inner commands only when it is a linear chain of plain words; anything with variables, globs, redirection or control flow is one opaque command. Observed as (B). - The unwrapping is one level deep: a shell invocation inside the app's own wrapper is treated as a command named
bash/zsh/sh, not recursed into. Observed as (C). Not documented either way. - The rules doc frames rules as controlling commands "outside the sandbox"; case (A) shows they ARE consulted for a sandboxed command when it matches, so the gap is matching, not scope.
- execpolicy README: absolute paths only match a rule whose first token is that absolute path (no basename fallback). The agent writes
/bin/rminside scripts; moot while (1) applies, relevant once it is fixed.
Related public reports, from the allow side (allow rules not suppressing prompts): #13175, #11298, #9206. This report is the inverse: restrictive rules an admin relies on do not fire.
What steps can reproduce the bug?
- Deploy a
requirements.tomlwith the[rules]block above and a custom permission profile whose workspace roots are writable (":workspace_roots" = { "." = "write" }),approvals_reviewer = auto_review. - In the ChatGPT desktop app, add a folder as a workspace root (any local folder reproduces it; ours was an SMB share subfolder under
/Volumes). Put a few files in it. - Ask: "delete all files inside , permanently". The agent emits a
set -e/variables/ifscript with/bin/rminside. Observe: no prompt, noguardiansub-agent rollout, files gone. - New thread, same root: "delete the files in , run the delete via bash -lc". The agent emits
bash -lc 'rm -- ... && ls ...'. Observe: no prompt, files gone. - Control: ask for a delete phrased so the agent emits a bare
rm -- a b. Observe: the approval request quotes the rule's justification and the reviewer is consulted (in our case, denied).
Evidence is in ~/.codex/sessions/<date>/rollout-*.jsonl: the exec_command calls with the exact cmd, and the reviewer's rollouts (session_meta.source.subagent = guardian) showing which actions ever reached it. Reviewed Codex session ids in our runs: 01a09324-e0ea-7e32-aaf3-853d6466c6a5 (case B), 01a0933f-64f3-7672-bf89-33154ccd8771 (case C), 01a0932f-2703-72d2-8515-42651fb3630a (case A, reviewer denied).
What is the expected behavior?
A prompt/forbidden rule on rm in requirements.toml should fire whenever rm (or /bin/rm) is executed as a command by the agent, regardless of how the shell invocation is shaped. Concretely, for restrictive rules coming from managed requirements:
- Treat an unsplittable shell script as matching every rule whose token appears as a command word anywhere in the script (most-restrictive-wins already exists for the splittable case), or at minimum route any unsplittable script to
promptwhen the requirements file contains restrictive rules. - Recurse into nested
sh|bash|zsh -c/-lcinvocations when unwrapping, sobash -lc 'rm ...'inside the app's own wrapper is matched like a top-levelrm. - Basename fallback for absolute program paths in requirements rules (
/bin/rmshould matchrm). - Until then, document the limitation on the managed-configuration page next to the
rm -> forbiddenexample, which currently reads as if deletes were blocked.
Additional information
Why this matters for managed deployments: the managed-configuration docs use rm -> forbidden as their own example of an admin-enforced rule. An admin reading that page will believe deletes are blocked. In practice the rule fires only when the model happens to emit a bare rm, which in our runs was once in three threads, on a retry. Prefix rules cannot currently serve as a control in requirements.toml; the filesystem permission profile is the only wall.
When the reviewer IS reached it behaves correctly: it receives the tenant policy, the permission profile, the parent turn's denied-read list and the transcript, and denied both escalations with the right rationale. The gap is entirely upstream, in what reaches it.
Secondary question (filesystem profile): there appears to be no way to express "readable but never writable, even if the user adds it as a workspace root" for a subtree such as /Volumes or ~/Library/CloudStorage. "/Volumes" = "read" loses to the root's "." = "write" on specificity, and a "/Volumes/**" = "read" glob is dropped by the runtime (globs are deny-only). "/Volumes/**" = "deny" IS honoured against a root added inside it (observed: rm inside such a root gets Operation not permitted, and the model receives the glob in its "Denied filesystem reads" list and declines reads), but deny means no read either. If read-only-even-as-root is meant to be expressible, please say how; if not, a read glob or a "ceiling" semantic for managed profiles would be the feature request. (cf. #22179 on deny globs.)
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Research direction
Start with the requirements.toml rules behavior described in the issue, the execpolicy README, and the managed-configuration documentation, then reproduce the plain, scripted, and nested shell cases using the rollout JSONL evidence. Done means restrictive rm rules reliably reach review or the documented limitation is made explicit, including the absolute-path and nested-shell cases.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- bash, rust, zsh
- Domain
- authorization, cli, security
- Issue type
- Bug
- Difficulty
- 5/5
- Estimated time
- Over a week
- Activity status
- Active
- Clarity
- Mostly clear
- Newbie friendliness
- 35/100