[RSI, security] Refuse sudo/doas privilege escalation in kernel bash - #2429
sethkarten wants to merge 34 commits into
Conversation
The kernel bash tool now refuses anything that invokes sudo or doas as a command word, before any process starts. Every other guard contains a command; privilege escalation leaves the containment entirely, and on a passwordless-sudo setup the escalation is silent. Detection is a quote-aware text scan: path spellings, quoted fragments, wrapper chains (env/nice/timeout/nohup/setsid/exec), assignment prefixes, redirections, group/subshell/brace positions, pipelines, xargs operands, shell -c and eval payloads, and heredoc bodies owned by a shell. Lookup forms, operand mentions, data heredocs, and comments stay allowed; unresolvable command positions with a sudo/doas mention fail closed. Bypasses stay deliberate and visible: bash(command, allow_sudo=True) or PI_BASH_ALLOW_SUDO=1. The environment bypass is frozen at kernel start, so a mid-session os.environ write is ignored, warned about, and stripped from child environments.
Prime Agent performance — completedPR Overall: 0 regressed · 0 improved · 42 no clear change.
Python runtime
Session transport
UI interactions
Sandbox cost: ~$0.1176 — no inference calls. Methodology and samplesMain resolved at 2026-09-23T00:56:05.989363+00:00. Harness
|
… in the sudo guard
… in the sudo guard
…top raising on huge brace ranges
…mand names alone
Round 3 of the sudo-guard review closed five threads and the option-table audit
behind them:
- faketime: -m and -f are boolean flags and the timestamp after the options is
positional (src/faketime.c, libfaketime 0.9.7-0.9.13), so `faketime -m now
sudo id` and `faketime -f T sudo id` no longer lose the command word. -p PID
and --date-prog PROG take a value.
- env: -a/--argv0 (coreutils 9.5+), --env0-from (9.12+), and BSD -P ALTPATH take
a value; the --block/--default/--ignore-signal options are optional-argument,
so they must not eat the utility name (`env --ignore-signal sudo id`).
- strace, ltrace, systemd-run, watch: every required-argument option each tool
documents (strace longopts, ltrace options.c, systemd-run -H/--uid/--gid and
its timer and property options, watch -q/--equexit and -s/--shotsdir), plus
parallel's operand options -C/-d/-J/-P/-s/-E and their long forms, and the
bundled-short-flag letters (strace was missing E, env a/P, watch q/s,
faketime still had f/m). Entries for options the tool does not have
(systemd-run --drop-in/--kill-who/--wait-timeout, parallel --ssh) are gone:
a phantom entry swallows the command word, which a guard must not do.
- A brace range past CPython's 4300-digit conversion limit fails closed as an
over-cap range instead of raising ValueError out of the synchronous bash().
- The letters net now covers only words that carry quoting or expansion, so
plain program names are judged by their case-folded basename: `sudoku`,
`sudo-report`, and `s-u-d-o` run (a real bash check with a sudo shim resolves
them to their own programs), while `SUDO`/`Sudo` (the tool on a
case-insensitive filesystem), a glob that can match it (`/usr/bin/su*`),
`${SUDO_CMD:-sudo}`, and `su do` still refuse.
Tests: the delivered tables against the pre-fix module (`git show c8d4358`)
fail on 63 assertions over 34 rows plus the brace-range ValueError, and pass
against this module.
… short bundles Two review vectors were confirmed against real bash and the guard's own spawn path at the previous head: - `time -p sudo id`: `time` is skipped as a keyword, but its own flag was then judged as the command word, so the walk never reached `sudo`. `_scan_segment` now skips `time`'s own flags before the command position. - `bash --rcfile FILE -c 'sudo id'`: `_glued_payload` read the `c` of the long option as a bundled `-c` (payload `file`) and stopped the walk before the real `-c` operand. Glued payloads now come only from short bundles. With a fake `sudo` shim first on PATH, both commands ran the shim (`sudo id`) at the previous head and both are refused before any process starts now.
`[[:lower:]]udo id` was allowed: the bracket scanner stopped at the first `]`, so the body of `[[:lower:]]` was only `[[:lower:`, the pattern could never match, and `re.compile` even warned about a possible nested set. With a file in the working directory that the pattern matches, real bash expands the word and then resolves `sudo` on PATH, so the escalation ran. The scanner now finds the closing bracket past `[:name:]` classes and expands each class to its regex ranges, so `[[:lower:]]udo` and `[[:alpha:]]udo` refuse like `sud[o]` and `su?do`. Spellings that cannot match either tool (`s[[:upper:]]do`, `[[:digit:]]udo`, `[![:lower:]]udo`) stay allowed, and an unnamed class becomes a single-character match, which fails closed. Probe: with a `sudo`-named file in the working directory and a fake sudo shim first on PATH, the previous head allowed `[[:lower:]]udo id` and the shim ran (`SUDO-INVOKED: id`); the guard refuses before any process starts now. Also records the accepted measured heredoc cost in the changeset fragment.
`_matches_sudo_pattern` tested the expanded class body for a leading `!`, but the `[:graph:]` (`!-~`) and `[:punct:]` ranges start with `!` themselves, so those classes were read as negation. `[[:punct:]]udo id` was refused although `s` is not punctuation, and `[[:graph:]]udo` refused through an accidental `[^-~]` rather than through the class it names. Negation now comes from the literal first character of the bracket body, before the class expands, so each class matches exactly the characters bash matches: `[[:graph:]]udo` and `[[:graph:]]oas` refuse, while `[[:punct:]]udo` and `[![:graph:]]udo` stay allowed.
Bash's command hash maps a name to the file it resolves to, and
`hash -p pathname name` installs such an entry by hand, so a later `name` runs
`pathname` however the name looks:
hash -p /usr/bin/sudo elevated; elevated id
was allowed and ran the tool. The registration now scans as the command it
runs, so the registered name is judged like an alias and its operands are
resolved as usual, and a registration the guard cannot read
(`X=/usr/bin/sudo; hash -p $X elevated; elevated id`) is refused with its own
message, because that entry could point anywhere. `hash` without `-p` only
reads or clears the table, so `hash`, `hash -r`, `hash -t sudo` and
`hash -p /usr/bin/ls ll` stay runnable.
Probe: with a fake sudo shim first on PATH, the previous head allowed both the
literal and the `$X` form and the shim ran (`SUDO-INVOKED: id`); both refuse
before any process starts now.
The changeset records `hash -p` as covered and names two accepted residuals
with their probe evidence: a renamed or copied tool at another path, and an
extglob spelling once `shopt -s extglob` is on.
|
[written by prime-agent] |
|
[written by prime-agent] |
| "xargs": _XARGS_OPERAND_OPTIONS, | ||
| "parallel": _PARALLEL_OPERAND_OPTIONS, | ||
| } | ||
| _LAUNCHER_OPERAND_LETTERS: dict[str, str] = {"xargs": _XARGS_OPERAND_LETTERS, "parallel": "jNnLSaI"} |
There was a problem hiding this comment.
Parallel bundle letters omit options
High Severity
_LAUNCHER_OPERAND_LETTERS for parallel is jNnLSaI and omits the value-taking shorts already listed in _PARALLEL_OPERAND_OPTIONS (C, d, D, E, J, P, s). A boolean-plus-value bundle therefore is not recognized, the next word is taken as the command, and the real sudo/doas operand is never scanned.
Reviewed by Cursor Bugbot for commit 6fa799c. Configure here.
| return True | ||
| letters = "".join(char for char in candidate if char.isalpha()).lower() | ||
| if ("sudo" in letters or "doas" in letters) and not _PLAIN_COMMAND_NAME.fullmatch(name): | ||
| return True |
There was a problem hiding this comment.
Windows sudo.exe names are allowed
Medium Severity
_word_names_sudo compares the case-folded basename to sudo/doas only. A Windows sudo.exe / doas.exe spelling does not match, and _PLAIN_COMMAND_NAME treats the dotted name as a plain program so the letters fallback does not fire. Git Bash will still execute that binary.
Additional Locations (1)
Reviewed by Cursor Bugbot for commit 6fa799c. Configure here.
| "xargs": _XARGS_OPERAND_OPTIONS, | ||
| "parallel": _PARALLEL_OPERAND_OPTIONS, | ||
| } | ||
| _LAUNCHER_OPERAND_LETTERS: dict[str, str] = {"xargs": _XARGS_OPERAND_LETTERS, "parallel": "jNnLSaI"} |
There was a problem hiding this comment.
🔴 Critical rlm/bash.py:500
Bundled parallel options containing -C, -d, -E, -J, -P, or -s bypass the sudo scan: _split_option fails to recognize the value-taking option, so _scan_xargs treats its operand as the command and never reaches sudo. Add those required-operand letters to _LAUNCHER_OPERAND_LETTERS.
- _LAUNCHER_OPERAND_LETTERS: dict[str, str] = {"xargs": _XARGS_OPERAND_LETTERS, "parallel": "jNnLSaI"}
+ _LAUNCHER_OPERAND_LETTERS: dict[str, str] = {"xargs": _XARGS_OPERAND_LETTERS, "parallel": "jNnLSaICdEJPs"}🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @prime-agent-runtime/src/rlm/bash.py around line 500:
Bundled `parallel` options containing `-C`, `-d`, `-E`, `-J`, `-P`, or `-s` bypass the sudo scan: `_split_option` fails to recognize the value-taking option, so `_scan_xargs` treats its operand as the command and never reaches `sudo`. Add those required-operand letters to `_LAUNCHER_OPERAND_LETTERS`.
Evidence trail:
prime-agent-runtime/src/rlm/bash.py:402-414, 496-500, 1533-1553, 1651-1696 at commit 6fa799c7; prime-agent-runtime/test/test_bash_sudo_guard.py:227-289 at commit 6fa799c7; git diff MERGE_BASE REVIEWED_COMMIT -- prime-agent-runtime/src/rlm/bash.py; https://www.gnu.org/software/parallel/man.html; https://github.com/martinda/gnu-parallel/blob/master/src/parallel
| if not hash_alias_names: | ||
| return value | ||
| target = hash_alias_names.get(value) | ||
| if target is None or value in _SHADOWPROOF_BUILTINS: |
There was a problem hiding this comment.
🟡 Medium rlm/bash.py:1797
hash -p /usr/bin/sudo echo causes echo hi to be rejected even though Bash executes its echo builtin and never consults the hash table, so valid builtin commands are incorrectly blocked. _SHADOWPROOF_BUILTINS omits ordinary builtins such as echo, cd, printf, and read; include all non-shadowable Bash builtins when resolving registered commands.
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @prime-agent-runtime/src/rlm/bash.py around line 1797:
`hash -p /usr/bin/sudo echo` causes `echo hi` to be rejected even though Bash executes its `echo` builtin and never consults the hash table, so valid builtin commands are incorrectly blocked. `_SHADOWPROOF_BUILTINS` omits ordinary builtins such as `echo`, `cd`, `printf`, and `read`; include all non-shadowable Bash builtins when resolving registered commands.
Evidence trail:
prime-agent-runtime/src/rlm/bash.py:501-508, 1044-1085, 1786-1799 (commit 6fa799c7cf9d0bd5ecce772345a529ee2f9c125f); prime-agent-runtime/test/test_bash_sudo_guard.py:425-433 (commit 6fa799c7cf9d0bd5ecce772345a529ee2f9c125f); Bash Reference Manual, Command Search and Execution: https://www.gnu.org/software/bash/manual/html_node/Command-Search-and-Execution.html
| if word.is_data or word.is_redirect: | ||
| return None |
There was a problem hiding this comment.
🔴 Critical rlm/bash.py:1666
_scan_xargs accepts xargs </dev/null sudo id without scanning sudo, so the guard can spawn a privileged command even though GNU xargs still launches sudo. The early return at _scan_xargs:1666 treats the redirect as the end of the command; skip redirects and continue locating the first non-flag command instead.
- if word.is_data or word.is_redirect:
+ if word.is_data:
return None
+ if word.is_redirect:
+ position += 1
+ continue🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @prime-agent-runtime/src/rlm/bash.py around lines 1666-1667:
`_scan_xargs` accepts `xargs </dev/null sudo id` without scanning `sudo`, so the guard can spawn a privileged command even though GNU `xargs` still launches `sudo`. The early return at `_scan_xargs:1666` treats the redirect as the end of the command; skip redirects and continue locating the first non-flag command instead.
Evidence trail:
prime-agent-runtime/src/rlm/bash.py:2793-2817, 1886-1894, 945-975, 1587-1599, 1651-1696 at commit 6fa799c7cf9d0bd5ecce772345a529ee2f9c125f. GNU Findutils manual: https://www.gnu.org/software/findutils/manual/find.html (xargs invocation and `-r` semantics: command runs once by default with empty input).
| token = words[candidate].value | ||
| if words[candidate].is_data or words[candidate].is_redirect: | ||
| break |
There was a problem hiding this comment.
🔴 Critical rlm/bash.py:1751
_hash_registered_command_names misses valid hash -p registrations when a redirect appears before the pathname, so hash -p >/dev/null /usr/bin/sudo safe; safe id is allowed instead of being recognized as a sudo invocation. Redirects are not builtin arguments and should be skipped while parsing the command rather than terminating operand collection.
- if words[candidate].is_data or words[candidate].is_redirect:
+ if words[candidate].is_data:
break
+ if words[candidate].is_redirect:
+ continue🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @prime-agent-runtime/src/rlm/bash.py around lines 1751-1753:
`_hash_registered_command_names` misses valid `hash -p` registrations when a redirect appears before the pathname, so `hash -p >/dev/null /usr/bin/sudo safe; safe id` is allowed instead of being recognized as a sudo invocation. Redirects are not builtin arguments and should be skipped while parsing the command rather than terminating operand collection.
Evidence trail:
Reviewed commit 6fa799c7cf9d0bd5ecce772345a529ee2f9c125f: prime-agent-runtime/src/rlm/bash.py:650-797 (redirect tokenization), 945-958 (registration use), 1044-1085 (registered-command detection), 1731-1783 (redirect terminates hash operand parsing). Git command: git show 6fa799c7cf9d0bd5ecce772345a529ee2f9c125f -- prime-agent-runtime/src/rlm/bash.py. Bash Reference Manual: https://www.gnu.org/software/bash/manual/html_node/Bourne-Shell-Builtins.html and https://www.gnu.org/software/bash/manual/html_node/Redirections.html
…v reads - _matching_paren is now #2373's canonical body (3-arg, quote- and escape-aware via _quote_span_end, copied byte-identical), so the sibling guards that ship it resolve `$(...)` interiors the same way regardless of merge order; _command_name moves into this guard's namespace as _pipe_shell_command_name (#2395's _fp_* precedent). - bash() computes _with_prefix(command) once and passes the script to both the guard and BashHandle (BashHandle(command, script=...), #2413's pattern), so a mid-call env change cannot make the scanned text differ from the executed text; a directly built handle is guarded instead of being a way around the guard. - _child_env() strips PI_BASH_ALLOW_PIPE_TO_SHELL unless the kernel started armed (#2429's strip for its bypass var), so a mid-session os.environ write cannot arm a nested kernel's frozen snapshot.
Four review findings, all validated against the code before fixing: - test suite renamed to test_bash_chmod_guard.py: #2373 ships its own rm/git suite at test_bash_guard.py, and the add/add collision would silently drop one suite at merge; the family convention is per-guard files (test_bash_sudo_guard.py, test_bash_forcepush_guard.py, test_bash_secret_echo_guard.py). - all 19 top-level helpers name-colliding with the sibling guard PRs (#2373, #2413, #2429) are namespaced (_chmod_*, _ChmodShellWord, _CHMOD_*), following #2395's _fp_ precedent, so merged bash.py cannot silently shadow one guard's helpers and break the other at runtime; adopting #2373's bodies was infeasible (guard-specific semantics, and ours pin the later red-team rounds: bounded nesting, quote-state paren matching, line-continuation folding). - the prefix env is read once per call and the guard validates exactly the script the handle runs: bash() computes the prefixed script and passes it via BashHandle(command, script=...), so a mid-call prefix change can no longer desync the scanned text from the executed text; a handle built directly on BashHandle is guarded at construction. - _child_env strips PI_BASH_ALLOW_DESTRUCTIVE_CHMOD unless it was set at kernel start, so a mid-session os.environ write cannot arm a nested kernel's frozen snapshot (the #2429 pattern for PI_BASH_ALLOW_SUDO). test_bash.py's three capturing_init wrappers pass the script kwarg through, matching #2413's edit of the same wrappers.
… prefix read Two review findings, both validated against the sibling guards: `_matching_paren` shipped a 2-arg copy while #2373/#2390/#2415 ship the same name as (text, open_index, end). The arity difference turns the silent redefinition at family-merge time into a guaranteed TypeError inside whichever guard loses, on any command carrying $(...). This PR now ships #2373's exact implementation (with its `_quote_span_end`), and every caller passes the region bound and reads the close the family way, so an unterminated span keeps its full remainder live instead of losing its last character to the end - 1 convention. `_guard_sudo` scanned `_with_prefix(command)` while `BashHandle` re-read `PRIME_AGENT_BASH_COMMAND_PREFIX` at spawn, so a mid-call environment change could make the scanned text differ from the executed text. `bash()` now computes the prefixed script once and hands it to `BashHandle(command, script=...)` (#2413's shape); the handle runs exactly the validated text, and a handle constructed directly is guarded inside `__init__` on the same one read, so the class is not a way around the guard. Regression tests: test_matching_paren_keeps_the_three_arg_family_contract (TypeError under the 2-arg body), test_direct_handle_construction_is_guarded, test_one_prefix_read_feeds_the_scan_and_the_spawn (two reads before), test_scanned_prefix_text_is_the_spawned_script, and unterminated substitution rows in both sample lists. The BashHandle `__init__` capturing wrappers in test_bash.py now pass the optional script through. Test-line budget stays 1:1 for this commit.
|
Both findings validated against the sibling guard branches and fixed in 6fcf5f8. 1. Regression coverage: 2. Regression coverage: Validation in the worktree: |
| # script: the class must not be a way around the guard. | ||
| if script is None: | ||
| script = _with_prefix(command) | ||
| _guard_sudo(script, False) |
There was a problem hiding this comment.
Constructor script skips sudo guard
Medium Severity
Passing script to BashHandle skips _guard_sudo, so a direct construction with a sudo/doas payload still starts a process. bash() stays protected, but the exported class remains an unguarded spawn path.
Additional Locations (1)
Reviewed by Cursor Bugbot for commit 6fcf5f8. Configure here.
| heredoc = operator.startswith("<<") | ||
| words.append( | ||
| _Word( | ||
| value=command[index:after] + target, |
There was a problem hiding this comment.
🟠 High rlm/bash.py:784
Redirection command substitutions such as echo >"$(sudo id)" bypass the sudo guard, so sudo executes during redirection setup. Redirect words are created without has_expansion, causing _scan_text and _scan_segment to skip them; mark expansion-bearing redirect targets so their substitutions are scanned.
- value=command[index:after] + target,
+ value=command[index:after] + target,
+ has_expansion=any(marker in target for marker in ("$", "`", "<(", ">(")),🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @prime-agent-runtime/src/rlm/bash.py around line 784:
Redirection command substitutions such as `echo >"$(sudo id)"` bypass the sudo guard, so `sudo` executes during redirection setup. Redirect words are created without `has_expansion`, causing `_scan_text` and `_scan_segment` to skip them; mark expansion-bearing redirect targets so their substitutions are scanned.
Evidence trail:
6fcf5f824805ed0b6082808e8278059cda8a1da4 — prime-agent-runtime/src/rlm/bash.py:650-697, 750-797, 960-990, 1023-1029, 1904-1913
GNU Bash Reference Manual: https://www.gnu.org/software/bash/manual/html_node/Redirections.html
GNU Bash Reference Manual: https://www.gnu.org/software/bash/manual/html_node/Simple-Command-Expansion.html
| # changes what one of these words does. External launchers the walk models | ||
| # (`env`, `timeout`, `strace`, `which`, `bash`, ...) are not builtins, so an | ||
| # entry pointing at sudo/doas does change what they run. | ||
| _SHADOWPROOF_BUILTINS = frozenset( |
There was a problem hiding this comment.
🟠 High rlm/bash.py:506
Disabling a builtin with enable -n lets hash -p redirect that name to sudo, but _SHADOWPROOF_BUILTINS still permanently exempts it. Thus hash -p /usr/bin/sudo type; enable -n type; type id is allowed even though Bash resolves the final type through the hash entry and executes sudo; track disabled builtins when applying this exemption.
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @prime-agent-runtime/src/rlm/bash.py around line 506:
Disabling a builtin with `enable -n` lets `hash -p` redirect that name to `sudo`, but `_SHADOWPROOF_BUILTINS` still permanently exempts it. Thus `hash -p /usr/bin/sudo type; enable -n type; type id` is allowed even though Bash resolves the final `type` through the hash entry and executes `sudo`; track disabled builtins when applying this exemption.
Evidence trail:
prime-agent-runtime/src/rlm/bash.py:501-508, 1059-1073, 1749-1817, 1904-1913 at commit 6fcf5f8; prime-agent-runtime/test/test_bash_sudo_guard.py:152-160, 369-370 at commit 6fcf5f8. Git commands: `git show 6fcf5f8 -- prime-agent-runtime/src/rlm/bash.py`; `git blame 6fcf5f8 -- prime-agent-runtime/src/rlm/bash.py`. Bash Reference Manual: https://doc.guix.gnu.org/bash/5.2.37/en/html_node/Bash-Builtins.html and https://doc.guix.gnu.org/bash/5.2.37/en/html_node/Command-Search-and-Execution.html
| break | ||
| if _is_flag_word(words[candidate]) and not token.startswith("--"): | ||
| if "p" in token[1:]: | ||
| has_pathname_option = True |
There was a problem hiding this comment.
🟡 Medium rlm/bash.py:1774
hash -tp /usr/bin/sudo safe; safe is treated as a registration, so the later harmless safe command is rejected even though Bash treats -t as taking precedence and performs a lookup. The parser checks for p without excluding the mutually exclusive -t/-d options; only install a mapping when -p is present without either higher-priority option.
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @prime-agent-runtime/src/rlm/bash.py around line 1774:
`hash -tp /usr/bin/sudo safe; safe` is treated as a registration, so the later harmless `safe` command is rejected even though Bash treats `-t` as taking precedence and performs a lookup. The parser checks for `p` without excluding the mutually exclusive `-t`/`-d` options; only install a mapping when `-p` is present without either higher-priority option.
Evidence trail:
Reviewed commit 6fcf5f8: `prime-agent-runtime/src/rlm/bash.py:1749-1800`, `:960-990`, `:1059-1100`. Bash implementation: https://github.com/tianon/mirror-bash/blob/master/builtins/hash.def. Bash reference: https://ftp.gnu.org/old-gnu/Manuals/bash/html_chapter/bashref_4.html. Verification commands: `git show REVIEWED_COMMIT:prime-agent-runtime/src/rlm/bash.py`; `git grep -n "_hash_registered_command_names\|_registered_command" REVIEWED_COMMIT -- prime-agent-runtime/src/rlm/bash.py`.
| @@ -0,0 +1 @@ | |||
| - Added a privilege-escalation guard to the kernel bash tool: sudo/doas command words are refused before any process starts, because silent privilege escalation outranks every other guard on passwordless-sudo setups. The scan and the spawn share one read of the command prefix, and a handle constructed directly from the BashHandle class is guarded the same way, so the class cannot bypass the scan. Covered: any path spelling, quote- and escape-folded spellings (`su"do"`, `"sudo"`, ANSI-C `$'su\x64o'` with out-of-range escapes kept literal instead of raising), brace/glob/letter obfuscation of the command word (`su{d,}o`, `{sudo,} id`, `sud[o]`, `su?do`, sequences such as `s{u..u}do` or `{1..9}`), and a brace group whose element count times the group chain exceeds the enumeration cap fails closed before it is built, wrapper chains (env/nice/timeout/nohup/setsid/exec/busybox/stdbuf/ionice/strace/ltrace/watch/faketime/systemd-run/chroot, including their value-taking options such as `env -S`, `env -C`, `timeout -s`, `stdbuf -o`, `nice -n`, `exec -a`, `watch -n` (its `-d`/`-t` and `faketime`'s `-m`/`-f` are boolean), each launcher's required-argument long options (strace's `--user`/`--argv0`/`--color` and the rest of its longopts, ltrace's `--indent`/`--library`, systemd-run's `-H`/`--uid`/`--gid` and its timer and property options, parallel's `-C`/`-d`/`-J`/`-P`/`-s`/`-E` and their long forms, `env -a`/`--argv0` and BSD `env -P`, `watch -q`/`-s`), bundled short flags such as `env -vu NAME`, and the operands of xargs options such as `-n`, `-I`, `-P`, plus the leading operands of `chroot`/`faketime` and `timeout`'s integer, floating-point, and suffixed durations), assignment prefixes, redirections, group/subshell/brace, `coproc`, and compound-command positions (`if`/`then`/`elif`/`else`/`while`/`until`/`for`/`do`/`case` bodies, with `for`/`select` loop variables and `case` subjects and labels read as syntax rather than commands, and `time`'s own flags skipped so `time -p sudo id` still reaches the command word), pipelines (`echo x | sudo tee /etc/hosts`), xargs and `parallel` operands and option operands (judged fail-closed because BSD and GNU disagree on which operands are optional), `find -exec`/`-execdir`/`-ok`/`-okdir` and `fd -x`/`-X` commands, alias bodies and `hash -p` command-hash entries in every spelling (a registered name scans as the command it runs, shell builtins such as `eval`/`command`/`exec` keep their own meaning because the hash table cannot shadow them, the word's own spelling is judged as well so `hash -r` cannot hide it, and an entry built from expansion is refused because the command it hides cannot be resolved), interpreter payloads (`sh -c`, bundled `-ce`, and glued payloads only from short bundles, so `bash --rcfile FILE -c 'sudo id'` is refused, `eval`), quoted `$(...)` spans (matched quote-aware, so `echo "$(printf ')'; sudo id)"` is refused), backticks, process substitutions (`<(cmd)`, `>(cmd)`) including a runner's script source, here-strings (`bash <<< 'sudo id'`, `bash<<<'sudo id'`, and glued multi-token payloads such as `bash<<<'sh -c "sudo id"'`, whose quoted target the tokenizer keeps as one word), and heredoc bodies owned by a shell or piped to one in the same text (the gate reuses the guard's own command-word walk, so `command`, `command -p`, `command --`, wrapper chains, xargs operands, and `find -exec`/`-execdir` operands are all treated as shell runners, while a `command -v`/`-V` lookup or a plain operand mention is not; an alias whose body reaches a runner (wrapper chains such as `env sh`, `command sh`, or `xargs -I{} sh -c {}` included) counts as a runner for the same gate and for the script-source scan). Lookup forms (`command -v sudo`, `command -V sudo`, `which sudo`) stay allowed, and `command` itself stays gated to those lookup flags instead of short-circuiting the walk, so `command sudo id` is refused; operand mentions (`man sudo`, `grep sudo file`), data heredocs, and comments stay allowed; unresolvable command-position words carrying a sudo/doas mention (`$CMD id`, `$SUDO id`) fail closed, while a name assembled so that no such spelling appears (a `printf` with hex escapes, a base64 payload piped to a shell) stays out of a name-based scan's reach, as do payloads nested deeper than the scan follows. Out of scope: `su`, trap actions, other interpreters' string payloads (`python -c`), Windows runas, script-file contents, launchers outside the modeled set that pick their own program or domain (`ssh`, `script` with its diverging util-linux and BSD option shapes, package runners such as `dx`/`npx`), and make recipes. Explicit bypasses: `bash(command, allow_sudo=True)` or `PI_BASH_ALLOW_SUDO=1`, where the environment bypass is frozen at kernel start so mid-session `os.environ` writes cannot neuter the guard; they are ignored and warned about loudly. It is a foot-gun guard, not a sandbox: an unmodeled launcher or value option, an abbreviated long option (`strace --verb 5 sudo id`), a renamed or copied tool at another path (a symlink to the tool and a plain copy both run the real binary, and `realpath` cannot see a copy), or an extglob spelling such as `s@(u|x)do` with `shopt -s extglob` on (the tokenizer splits the group, and shell-option state is not statically knowable), can still hide a command word, and `su`, other interpreters' string payloads, script files, and those bypasses all still reach root. A name that merely contains `sudo` is judged by its case-folded basename, so `sudoku`, `sudo-report`, and `s-u-d-o` stay runnable while `SUDO` still is the tool on a case-insensitive filesystem; a glob in the basename is judged as a pattern (`/usr/bin/su*` refuses), and a word that carries quoting or expansion still falls back to its letters. The launcher tables name only options their tool really has, because an entry for a phantom option swallows the command word. A glob pattern that can match `sudo`/`doas` is read as a pattern, POSIX classes included, so `[[:lower:]]udo` and `[[:alpha:]]udo` refuse exactly like `sud[o]` and `su?do`, while a spelling that cannot match either name (`s[[:upper:]]do`, `[[:digit:]]udo`, `[![:lower:]]udo`) stays allowed. Known measured cost, accepted for this pass and named as the follow-up: a command carrying many heredocs is resolved quadratically, because each heredoc scans the remaining text for its delimiter and then sweeps the words to mark the body as data. Measured on this module: 0.066s at 1000 heredocs and 1.34s at 5000 (about 100 KB of command text), reproduced by timing `_apply_heredocs` alone, and extrapolating to a command near the argv limit gives minutes. The fix is a single sweep over the collected body ranges; it is not taken here because it changes the scan machinery rather than closing a bypass. | |||
There was a problem hiding this comment.
🟠 High .changes/kernel-sudo-guard.md:1
Direct BashHandle("display", script="sudo id") bypasses _guard_sudo because the guard only runs when script is None, so the unscanned script can launch sudo. This changelog claim that direct BashHandle construction is guarded is therefore incorrect; either guard the supplied script or remove that claim.
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @packages/coding-agent/.changes/kernel-sudo-guard.md around line 1:
Direct `BashHandle("display", script="sudo id")` bypasses `_guard_sudo` because the guard only runs when `script is None`, so the unscanned script can launch `sudo`. This changelog claim that direct `BashHandle` construction is guarded is therefore incorrect; either guard the supplied `script` or remove that claim.
Evidence trail:
Reviewed commit 6fcf5f8. `prime-agent-runtime/src/rlm/bash.py:1904-1913` (`_guard_sudo`); `prime-agent-runtime/src/rlm/bash.py:2092-2108` (guard only when `script is None`); `prime-agent-runtime/src/rlm/bash.py:2166-2183` (supplied script executed); `prime-agent-runtime/src/rlm/__init__.py:11,591-592` (public export). `packages/coding-agent/.changes/kernel-sudo-guard.md:1` contains the incorrect claim.
… the late bypass from child env snimu review round 1 (three findings, all validated against real spawns before the fix): - bash() passed the prefix-joined text as BashHandle's command, so handle.command, repr(handle), and the completion notice showed the injected PRIME_AGENT_BASH_COMMAND_PREFIX script instead of the text the model submitted (reproduced: handle.command carried the prefix; a regression vs main, and it collides with the display pin #2413 adds to test_bash.py). Adopt #2413's shape: BashHandle(command, script=...) keeps the caller text for display and runs the guarded script; a handle built directly (script=None) pays the guard so the class is not a way around it. - _fp_upstream_info ran subprocess.run(shell, -c, probe, timeout=10) synchronously inside bash(), on the kernel's event loop: a wedged git froze the whole session for up to 10 seconds and then FAILED OPEN -- the timed-out probe counted as "not a repository" and the force push spawned anyway (reproduced: bash() returned in 10.02s with no refusal). The probe budget is now 2.0s (an event-loop bound; a local rev-parse finishes in tens of milliseconds) and a TimeoutExpired fails closed with its own refusal. - _child_env kept PI_BASH_ALLOW_FORCE_PUSH, so a mid-session os.environ write the guard's own warning says is ignored still armed nested kernels: a child kernel launched through bash() froze the late value into its launch snapshot and ran force-pushes unchecked (reproduced: the child really force-updated main). _child_env now strips it unless the launch-time snapshot authorizes it (the #2429 shape for PI_BASH_ALLOW_SUDO), and adopts #2373's canonical $BASH_ENV/$ENV and BASH_FUNC_name%% strips so the family merges in any order. Regression coverage: guard suite pins the direct-construction guard, the probe-timeout fail-closed bound, and the child-env strips; an end-to-end test launches a nested kernel through bash() after a late write and asserts the child kernel refuses; test_bash.py carries #2413's handle.command display pin and its capturing_init mocks accept the script= parameter.
|
Two problems in one helper: (1) it is same-name-different-body vs #2373's comment-aware version, so whichever PR merges second silently shadows the other; (2) the comment-blind join is fail-open in this PR's own guard: Adopting #2373's comment-aware body verbatim (the same alignment already done for [written by prime-agent] |
| double = not double | ||
| out.append(char) | ||
| index += 1 | ||
| return "".join(out) |
There was a problem hiding this comment.
Comment join hides later sudo
High Severity
_join_line_continuations drops a backslash-newline even after #, so a later sudo/doas is glued into the comment and never tokenized. Bash ends the comment at the newline and still runs that next line, so the guard allows a command that escalates.
Reviewed by Cursor Bugbot for commit 44f501f. Configure here.
| while position < len(following): | ||
| word = words[following[position]] | ||
| if word.is_data or word.is_redirect: | ||
| return None |
There was a problem hiding this comment.
Redirects abort sudo command walks
High Severity
The walk treats redirect tokens as argv boundaries. _scan_xargs returns on the first redirect, wrapper value options consume a following redirect as their operand, and hash -p stops collecting names at a redirect. The shell strips those redirections before argv, so sudo/doas still runs.
Additional Locations (2)
Reviewed by Cursor Bugbot for commit 44f501f. Configure here.
| flush() | ||
| if char == "\n": | ||
| segment_start = True | ||
| index += 1 |
There was a problem hiding this comment.
Unquoted backticks miss sudo substitutions
High Severity
_tokenize does not keep an unquoted backtick span as one word, so spaces inside `...` split the substitution. _expansion_spans then cannot recover the command text, and a leading safe word such as echo is judged instead of the inner sudo/doas.
Additional Locations (2)
Reviewed by Cursor Bugbot for commit 44f501f. Configure here.
| double = not double | ||
| out.append(char) | ||
| index += 1 | ||
| return "".join(out) |
There was a problem hiding this comment.
Comment continuations hide sudo commands
High Severity
_join_line_continuations still joins a backslash-newline when that backslash sits in a # comment, so a later sudo/doas word is absorbed into the comment and the scan allows the command. Real bash does not continue a comment across that newline, so the escalated command still runs.
Reviewed by Cursor Bugbot for commit fd88b1d. Configure here.
| double = not double | ||
| out.append(char) | ||
| index += 1 | ||
| return "".join(out) |
There was a problem hiding this comment.
Comment line-join bypasses sudo guard
High Severity
_join_line_continuations removes every unquoted backslash-newline before tokenization, including a trailing \ that bash already consumed as comment text. The later # scan then swallows the next line, so a comment-final backslash followed by a sudo/doas command is allowed while the real shell still runs it.
Additional Locations (1)
Reviewed by Cursor Bugbot for commit 612ac63. Configure here.
| while position < len(following): | ||
| word = words[following[position]] | ||
| if word.is_data or word.is_redirect: | ||
| return None |
There was a problem hiding this comment.
Redirects abort launcher walks
High Severity
Modeled launcher and wrapper walks treat a shell redirect as the end of the command rather than skippable syntax. _scan_xargs returns no violation on the first redirect, _skip_wrapper_operands stops there so a later flag or duration is judged as the command word, and _scan_find_execs skips an -exec operand that is a redirect. Forms such as xargs < file sudo …, timeout >out 5 sudo …, and find . -exec >out sudo … therefore run sudo/doas without a refusal.
Additional Locations (2)
Reviewed by Cursor Bugbot for commit 4d14983. Configure here.
| violation = _scan_text(body, depth + 1, parent_mentions_sudo) | ||
| if violation: | ||
| return violation | ||
| return None |
There was a problem hiding this comment.
Piped shell scripts not scanned
High Severity
A payload runner (sh, bash, source, .) without -c, a here-string, or a heredoc is treated as having no script, even when earlier pipeline words in the same text are that script. echo sudo id | sh and printf 'sudo id' | bash -s therefore execute sudo/doas while the guard returns no violation. Heredocs piped to a runner are scanned; ordinary pipeline data is not.
Additional Locations (1)
Reviewed by Cursor Bugbot for commit 4d14983. Configure here.
| start += 1 | ||
| while start < len(words) and _is_flag_word(words[start]): | ||
| start += 1 | ||
| continue |
There was a problem hiding this comment.
Path-qualified time skips walk
High Severity
time is handled only as an exact-word keyword and is not in _WRAPPERS, so a path-qualified invocation is treated as the command itself. /usr/bin/time sudo id (and the same with -p) runs GNU time on sudo/doas and is not refused, while the bare keyword form is.
Additional Locations (1)
Reviewed by Cursor Bugbot for commit 4d14983. Configure here.
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 1 potential issue.
There are 24 total unresolved issues (including 23 from previous reviews).
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit 2b0a7bb. Configure here.
| start += 1 | ||
| while start < len(words) and _is_flag_word(words[start]): | ||
| start += 1 | ||
| continue |
There was a problem hiding this comment.
GNU time launcher evades scan
High Severity
time is handled only as a bare keyword, so /usr/bin/time and time after env or command are not treated as launchers. GNU time value options such as -f and -o then become the apparent command word and a following sudo/doas is allowed.
Additional Locations (1)
Reviewed by Cursor Bugbot for commit 2b0a7bb. Configure here.


Summary
sudo/doasbefore any process starts.sudo,/usr/bin/sudo,doas), quoted-fragment words (su"do"), assignment prefixes, redirections, group/subshell/brace and pipeline positions, compound-command bodies (if/while/until/for/do/case), wrapper chains (env,nice,timeout,nohup,setsid,stdbuf,command,builtin,exec,busybox, includingexec -a NAME),xargsoperands and runner payloads, interpreter-cpayloads (bundled flags and glued/ANSI-C quoted payloads included),evalarguments, interpreter-owned here-document bodies, and$(...)/backtick/<(...)/>(...)substitution spans. An unresolvable command-position word with a sudo/doas mention in the text fails closed.command -v/-V sudo,which sudo,type sudo,whereis sudo), operand mentions (man sudo,grep sudo file.md), single-quoted data and comments, data heredocs, clean process substitutions, and unresolvable words with no sudo/doas mention.bash(command, allow_sudo=True), orPI_BASH_ALLOW_SUDO=1honored only when the kernel is started with it (frozen at import). A mid-sessionos.environwrite never unlocks the guard: it triggers one loud warning and is stripped from_child_env()unless the kernel was launched with it.bash(), docstring paragraph, changeset fragment.526598b4d, all closed inc8d435875): brace sequence expansion (s{u..u}do id), the brace cap enforced from counts before any expansion is built (1M alternatives: 4.0s/80MB -> 0.15s brace work), out-of-range ANSI-C code points no longer raising, loop variables andcaselabels no longer judged as command words, here-string payloads attached to the operator (bash<<<'sudo id',bash<<<'sh -c "sudo id"'), the parent sudo mention carried into theenv -S/alias recursions, and an explicit launcher set (strace,ltrace,watch,faketime,systemd-run,chroot,parallel,fd -x/-X) modelled with each tool's real value options. Fourfd -X <flag>claims were dismissed with probe evidence: real fd treats everything after-x/-Xas the command line, so it errors on a flag there and never runs sudo.c8d435875):faketime -mis a boolean flag, so the walk no longer eats the timestamp andfaketime -m now sudo idis refused; a brace range past CPython's 4300-digit integer limit fails closed instead of raising out ofbash();sudoku/sudo-reportare runnable again, because the letter net now covers only words that carry quoting or expansion (${SUDO_CMD:-sudo},su do) while plain program names are judged by their case-folded basename; and the launcher tables were completed from each tool's own option table. One thread was dismissed with probe evidence: a doubled backslash before a newline runs the wordsu\(command not found) and thendo idunder bash 5.3.15, neversudo, so the suggested patch would have added a false refusal (row kept in the non-matching table).faketime -fis boolean as well, and its options stop at the timestamp (faketime -f '+3d' sudo idwas allowed);env -a/--argv0(coreutils 9.5+),--env0-from(9.12+), and BSDenv -P ALTPATHtake a value, while the--block/--default/--ignore-signaloptions are optional-argument and must not eat the utility name (env --ignore-signal sudo idwas allowed); strace's required-argument long options (--user,--argv0,--color,--detach-on, ...), ltrace's (--indent,--library, ...),systemd-run -H, andwatch -q/-stake a value; and the bundled-short-flag letters were corrected (strace missingE, env missinga/P, faketime still had the booleanf/m, watch missingq/s). Each is probe-verified: the named vector was allowed atc8d435875and refuses now. Two entry classes stay as they are, deliberately:ltrace -dandsystemd-run --drop-in/--kill-who/--wait-timeoutare not options of those tools in current upstream, but they are rejected by the tool itself, so treating their operand as a value only fails closed.systemd-run --drop-in/--kill-who/--wait-timeoutare not systemd-run options, so those entries swallowed the command word (systemd-run --drop-in sudo idwent from refuse to allow); parallel's short operand options-C/-d/-J/-P/-s/-Ewere missing from the operand table, which let real GNU parallel runparallel -C , sudo id(verified by executing upstream parallel with a sudo shim); systemd-run's timer and property options (--on-calendar,--on-active,--timer-property, ...) were missing; and the plain-name exemption was keyed on the whole word, so/usr/bin/sudokustayed refused. Keying every test on the basename also closed a pre-existing hole:/usr/bin/su*andsud?glob to the tool under real bash and were allowed before, and now refuse. One finding is reported rather than fixed:getopt_longaccepts an unambiguous abbreviation of a long option, sostrace --verb 5 sudo idstill hides the operand; that needs a mechanism change (prefix resolution) rather than a table entry, and it is named as a documented gap in the changeset.e75eabcb8, all closed in526598b4d): each vector was validated against real bash with a fakesudo/doasshim on PATH before any fix. Closed with regression rows: ANSI-C$'su\x64o'decoding, quote-aware$(...)paren matching,coproc,env -S/-Cand wrapper value options (separate, glued, and bundled short flags), xargs option operands, bundled-cflags,find -exec/-execdir/-ok, runner scripts from here-strings and process substitutions,$'...'/brace/glob/letter obfuscation of the command word, depth limits and unresolvable expansions failing closed, payload recursion keeping the parent's sudo mention, alias bodies (including wrapper-first bodies, resolved through the guard's own walk), and heredoc bodies piped to a runner, includingcommand/command -p/command --, wrapper chains, xargs, andfind -execroutes. Review rounds also fixed an O(n^2) brace scan (32k braces: 7.0s -> 13ms) and two over-refusals found by the same review.su, trap actions, other interpreters' string payloads,runas, script-file contents, and tools outside the modeled wrapper set that run a program on your behalf (watch,fd -x,parallel,ssh, make recipes).time -p sudo id(the walk judgedtime's own flag as the command word) andbash --rcfile FILE -c 'sudo id'(thecof a long option was read as a bundled-c, so the real payload was never scanned). Both ran a fakesudoshim on PATH at the previous head and both are refused before any process starts now. One is a false positive: upstream strace declares--color,--kvm, and--decode-pidsas required-argument, so modelling them as value-taking matches strace and the suggested change would have openedstrace --color always sudo idand its siblings. One stays open:_apply_heredocsis quadratic in the heredoc count (measured 1.2s to 1.3s at 5000 heredocs, ~100 KB of command text), which needs a scan-machinery change rather than a bypass fix, so it is recorded below as an accepted cost with the fix shape named as the follow-up. The post-freeze re-review then filed three more threads:s'{u,x}'do(stated scope: a word that carries quoting falls back to its letters, and real bash runs the literal word) andcmd=(sudo id)(fail-closed: the same text can execute the array) were dismissed with probe evidence, and the POSIX-class spelling[[:lower:]]udowas a confirmed gap and is fixed, so the bracket parser now reads classes like[:lower:]as part of the enclosing expression.The re-review of that head filed five more threads. One was a real defect in the lines above and is fixed: bracket negation was read after class expansion, so the
[:graph:]and[:punct:]ranges that start with!read as negation, which over-refused[[:punct:]]udoand made[[:graph:]]udorefuse only by accident; negation now comes from the literal body first and every class matches bash exactly. Three were dismissed with probe evidence or a scope cite:strace --verb 5 sudo id(the changeset's named prefix-abbreviation gap),BASH_ENVstartup files (script-file contents, out of scope), andPRIME_AGENT_BASH_SHELL=/usr/bin/python3(the fixed shell fence fails on its first line, so the command text never executes).hash -p PATH NAMEclosed in the final pass: the registration is modeled (below).The last pass closed the remaining three threads.
hash -p pathname nameis modelled, ported from the chmod guard's round-7 shape: the registered name scans as the command it runs, so its operands are still resolved, and a registration built from expansion is refused with its own message, because that entry could point anywhere. A bot pass on that port then found three defects in it, all fixed: the pathname glued to the flag (hash -p/path name) was dropped, only one name was registered although bash binds every operand after the pathname, and a name registered to a shell or wrapper was judged by its own spelling instead of the file it runs. A registered name now scans as that file wherever the name decides a judgement, sohash -p /bin/bash script; script -c 'sudo id'andhash -p /usr/bin/env e; e sudo idare refused, while harmless payloads and registrations that are never invoked stay allowed. A further pass closed five more findings:timeout's floating-point and suffixed durations (timeout 0.1 sudo id) now read as the wrapper's operand, ahash -pentry can no longer shadow a spelling the walk models (eval,command,exec,builtin,alias), nor hide behind the word's own name (echo hash -p /bin/ls sudo; sudo id,hash -p /bin/ls sudo; hash -r; sudo id), each verified to run a sudo shim at the earlier head and to refuse now; and a command name assembled with no sudo/doas spelling in the text is recorded as a stated limit rather than a defect, since it is unreachable for a name-based scan in any position. A further pass closed three more contained defects the same way: only real shell builtins are now shadowproof for ahash -pentry (hash -p /usr/bin/sudo env; env idran sudo at the earlier head), a registration whose name carries glob or brace metacharacters is refused as unreadable, and a redirect between a shell's-cand its payload no longer ends the walk (bash -c >/tmp/out 'sudo id'). Every one is verified to run a sudo shim at the earlier head and to refuse now. A renamed or copied tool at another path, and an extglob spelling such ass@(u|x)dowithshopt -s extglobon, are accepted as declared residuals in the changeset, each with its probe evidence.Tests
prime-agent-runtime/test/test_bash_sudo_guard.py: 16 tests. Detection tables (246 matching rows covering every shape above, 104 non-matching rows), behavioral refusal asserted over the full matching table, refusal-before-spawn (BashHandlemocked to raise), refusal message documents both bypasses, kwarg bypass passes and does not leak into later commands, frozen env bypass honored at launch, mid-session env write warns exactly once and is still refused,_child_env()strips the bypass var unless launch-authorized.command sudo idtreated as a lookup, quoted$(...)/backtick payloads) plus a process-substitution gap (diff <(sudo id) x) were found in review and fixed, each with new table rows. Round-1 and round-2: the tables grew from 53 to 214 matching rows and 33 to 87 non-matching rows; re-running the delivered table against the pre-fix module (git show e75eabcb8:prime-agent-runtime/src/rlm/bash.py) shows 145 of the 214 matching rows were allowed before the fixes, and every one refuses now; plus bounded tests for the brace flood, the sequence/cap bounds, and the syntax forms.Validation
uv run python -m unittest test_bash_sudo_guard test_bash(fromprime-agent-runtime/test): 72 tests, OK.Full runtime suite (
uv run python -m unittest discover -s test): OK; the two environment-dependenttest_bashcases (orphan-journal enrollment, Windows shell teaching error) fail identically on pristine origin/main when the agent kernel's own env vars are inherited — unrelated to this change.npm run checkat the repo root: green (biome — 840 files, no fixes; test-policy against origin/main; tsgo; installer; browser-smoke).Round-3 validation: the delivered tables against the pre-fix module (
git show c8d435875:prime-agent-runtime/src/rlm/bash.py) fail on 63 assertions covering 34 rows plus the brace-rangeValueError; against this module the file passes (16 tests, OK). Full runtime suite 337 tests with the same 2 pre-existing environmentaltest_bashfailures, reproduced from a pristineorigin/mainexport oftest_bash.py(1 failure, 1 error).npm run checkgreen. Test-line budget: 535 net test additions against 1001 source additions at the merge base withorigin/main(passes); measured against the round-2 headc8d435875the delta is 61 test lines against 25 source lines, which exceeds the 1:1 delta rule only because the new source lines are option-name literals and comments, which the counter does not count as source.After the red-team fixes on
c8d435875:uv run python -m unittest test.test_bash_sudo_guard16 tests OK; full runtime suite 337 tests with the same 2 pre-existing environmental failures;npm run checkgreen (test-policy measured against the merge base withorigin/main).Known measured cost, accepted for this pass and named as the follow-up: heredoc bodies resolve quadratically, because
_apply_heredocsscans the remaining text for each delimiter and then sweeps the words to mark the body as data. Measured on this module: 0.066s at 1000 heredocs and 1.34s at 5000 (about 100 KB of command text), reproduced by timing_apply_heredocsalone; a command near the argv limit extrapolates to minutes. Fix shape: collect the body ranges once and mark the tokens in a single sweep. It is not taken in this frozen pass because it changes the scan machinery rather than closing a bypass.Note
High Risk
Security-critical containment logic with a large custom shell parser; false refusals or remaining documented bypass paths would affect every agent shell invocation.
Overview
Adds a privilege-escalation guard to kernel
bash()so commands that can reachsudoordoasare rejected before any subprocess starts, via a static shell-aware scan of the prefixed script text.The scanner tokenizes and walks command positions, wrapper chains, interpreter payloads (
-c,eval, here-strings, heredocs piped to runners), expansions, aliases, andhash -pregistrations, with fail-closed handling for obfuscated spellings and unresolvable expansions.bash(..., allow_sudo=True)andPI_BASH_ALLOW_SUDO=1only if set at kernel import are the documented bypasses; mid-session env writes are warned, ignored for the guard, and stripped from_child_env(). DirectBashHandle(...)construction runs the same check, andbash()uses one_with_prefixread for both scan and spawn.Ships a coding-agent changeset note and
test_bash_sudo_guard.py(large matching/non-matching tables plus integration tests); existingtest_bashmocks were updated for the new handlescriptargument.Reviewed by Cursor Bugbot for commit 2b0a7bb. Bugbot is set up for automated code reviews on this repo. Configure here.
Note
Refuse
sudo/doasprivilege escalation in kernelbashtoolhashregistrations, wrapper chains (env,nice, …), and launchers (xargs,parallel,find -exec) to detect any reachablesudo/doasinvocation, including obfuscated spellings via globs and brace expansions.PrivilegeEscalationRefusalErrorbefore process creation. Two bypasses exist: a per-callallow_sudo=Trueon thebashtool, and a frozen startup-environment bypass captured once at module load.sudo/doas(e.g. inside a non-runner heredoc body) remain allowed; only executable command positions and runner-owned script payloads are refused.hash -pregistrations, and nesting beyond the depth limit all produce a refusal rather than a silent pass. Mid-session writes to the bypass env var are ignored and stripped from child environments (_child_env), so only a startup-enabled bypass persists.Macroscope summarized 2b0a7bb.