[RIP-SEC] execpolicy `forbidden`/deny rules are bypassable by spelling argv[0] as an unregistered path (`/tmp/git` vs `git`)

Open 💬 1 comment Opened Aug 5, 2026 by scadastrangelove

Summary

An execpolicy deny/forbidden rule is enforced for the bare program name and for the program's
registered host-executable paths, but not for the same program invoked through an unregistered path
(a copy or symlink at /tmp/git, ./git, etc.). Such a path matches no rule and is not denied.

Where

codex-rs/execpolicy/src/policy.rs:

  • match_exact_rules keys on cmd.first() literally, so /tmp/git != the rule key git.
  • match_host_executable_rules (used in production with resolve_host_executables = true, see

codex-rs/core/src/exec_policy.rs) resolves the basename but returns no match when argv[0] is not
one of the registered host_executables_by_name paths.

  • Present on current main.

Reproduction

With a rule prefix_rule(pattern=["git","push"], decision="forbidden") and git's host path pinned
to /usr/bin/git, evaluated with resolve_host_executables = true:

git push            -> Forbidden      (exact name)
/usr/bin/git push   -> Forbidden      (registered host path)
/tmp/git push       -> NOT Forbidden  (unregistered path — bypass)

Impact

A deny/forbidden rule can be evaded by invoking the target program through an unregistered path (a
symlink or a copy in the writable workspace). The bypass defeats the denylist only; the OS sandbox
still applies and the attacker needs a runnable copy at the alternate path. Low severity — a
denylist-hardening gap.

Suggested fix

Resolve argv[0] to a canonical executable identity (canonicalize / follow symlinks) before matching,
or match deny rules on the basename regardless of host-path registration, so a forbidden program
can't be un-forbidden by relocating or symlinking it.

Prior art / not a duplicate

Same family as issue #13095 (Unicode-confusable characters bypassing exec-policy matching) — both are
argv[0]-normalization gaps that defeat matching including forbidden — but the mechanism here is
absolute/relative path form, not confusables.

---
Found with the rust-in-peace pipeline
(AI-assisted Rust vulnerability research).

View original on GitHub ↗

1 Comment

scadastrangelove · 21 days ago

Candidate fix (with a regression test) for this issue. I can't open a PR directly —
openai/codex restricts pull requests to collaborators — so the patch is inline below,
and also on a branch you can pull/cherry-pick: https://github.com/scadastrangelove/codex/tree/codex-execpolicy-forbidden-lookalike

<details><summary>Patch (git diff)</summary>

diff --git a/codex-rs/execpolicy/src/policy.rs b/codex-rs/execpolicy/src/policy.rs
index 3102264..939d8f1 100644
--- a/codex-rs/execpolicy/src/policy.rs
+++ b/codex-rs/execpolicy/src/policy.rs
@@ -317,11 +317,17 @@ impl Policy {
         let Some(rules) = self.rules_by_program.get_vec(&basename) else {
             return Vec::new();
         };
-        if let Some(paths) = self.host_executables_by_name.get(&basename)
-            && !paths.iter().any(|path| path == &program)
-        {
-            return Vec::new();
-        }
+        // If `basename` is a registered host executable but the invoked absolute
+        // path is not one of its registered locations, the caller is running a
+        // look-alike binary (for example a copy of `git` dropped at `/tmp/git`).
+        // Such a binary must not inherit the name's *allow* rules — but it must
+        // also not silently escape the name's *forbidden* rules. Previously all
+        // rules were dropped here, which let an unregistered look-alike bypass a
+        // deny rule keyed on the basename. Keep the deny rules, drop the rest.
+        let unregistered_lookalike = self
+            .host_executables_by_name
+            .get(&basename)
+            .is_some_and(|paths| !paths.iter().any(|path| path == &program));
 
         let basename_command = std::iter::once(basename)
             .chain(cmd.iter().skip(1).cloned())
@@ -329,6 +335,9 @@ impl Policy {
         rules
             .iter()
             .filter_map(|rule| rule.matches(&basename_command))
+            .filter(|rule_match| {
+                !unregistered_lookalike || rule_match.decision() == Decision::Forbidden
+            })
             .map(|rule_match| rule_match.with_resolved_program(&program))
             .collect()
     }
diff --git a/codex-rs/execpolicy/tests/basic.rs b/codex-rs/execpolicy/tests/basic.rs
index f6a86ad..9bbc6a7 100644
--- a/codex-rs/execpolicy/tests/basic.rs
+++ b/codex-rs/execpolicy/tests/basic.rs
@@ -893,6 +893,48 @@ host_executable(name = "git", paths = ["{allowed_git_literal}"])
     Ok(())
 }
 
+#[test]
+fn forbidden_rule_still_blocks_unregistered_lookalike_path() -> Result<()> {
+    // A `forbidden` rule keyed on a basename must not be bypassable by running a
+    // look-alike binary from an unregistered path (e.g. a copy of `git` dropped
+    // at `/tmp/git`). Allow/prompt rules are still (correctly) dropped for such a
+    // path — see `host_executable_resolution_ignores_path_not_in_allowlist` — but
+    // a deny rule must remain in force (fail-closed).
+    let allowed_git = host_absolute_path(&["usr", "bin", "git"]);
+    let lookalike_git = host_absolute_path(&["tmp", "git"]);
+    let allowed_git_literal = starlark_string(&allowed_git);
+    let policy_src = format!(
+        r#"
+prefix_rule(pattern = ["git"], decision = "forbidden")
+host_executable(name = "git", paths = ["{allowed_git_literal}"])
+"#
+    );
+    let mut parser = PolicyParser::new();
+    parser.parse("test.rules", &policy_src)?;
+    let policy = parser.build();
+
+    let evaluation = policy.check_with_options(
+        &[lookalike_git.clone()],
+        &allow_all,
+        &MatchOptions {
+            resolve_host_executables: true,
+        },
+    );
+    assert_eq!(
+        evaluation,
+        Evaluation {
+            decision: Decision::Forbidden,
+            matched_rules: vec![RuleMatch::PrefixRuleMatch {
+                matched_prefix: tokens(&["git"]),
+                decision: Decision::Forbidden,
+                resolved_program: Some(absolute_path(&lookalike_git)),
+                justification: None,
+            }],
+        }
+    );
+    Ok(())
+}
+
 #[test]
 fn host_executable_resolution_falls_back_without_mapping() -> Result<()> {
     let policy_src = r#"

</details>

Built and tested on main @c87a218 with toolchain 1.95.0.
_Found with the rust-in-peace pipeline._