Skip to content

[RSI, security] Refuse sudo/doas privilege escalation in kernel bash - #2429

Open
sethkarten wants to merge 34 commits into
mainfrom
cb-fix-sudo-guard
Open

sethkarten wants to merge 34 commits into
mainfrom
cb-fix-sudo-guard

Conversation

@sethkarten

@sethkarten sethkarten commented Sep 16, 2026

Copy link
Copy Markdown
Contributor

Summary

  • Adds a privilege-escalation guard to the kernel bash tool. Every other guard in the fleet ([RSI, security] Port the dirty-tree destructive-git guard to the kernel bash tool #2373 dirty-tree destructive git, [RSI, security] Refuse recursive-force rm that escapes the workspace in kernel bash #2384 recursive-force rm, [RSI, security] Refuse recursive chmod/chown that escapes the workspace in kernel bash #2390 recursive chmod/chown, [RSI, security] Refuse force-pushes to protected branches in kernel bash #2395 force-push, [RSI, security] Refuse secret-echo commands that leak into transcripts in kernel bash #2413 secret-echo, [RSI, security] Refuse curl|sh remote-code execution in kernel bash #2415 curl|sh) contains a command to a specific destructive class; privilege escalation leaves the containment entirely, and on a passwordless-sudo setup (CI runners, dev VMs, NOPASSWD rules) the escalation is silent. Kernel bash now refuses commands that invoke sudo/doas before any process starts.
  • Detection is a quote-aware word scan. Covered: bare and path spellings (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, including exec -a NAME), xargs operands and runner payloads, interpreter -c payloads (bundled flags and glued/ANSI-C quoted payloads included), eval arguments, interpreter-owned here-document bodies, and $(...)/backtick/<(...)/>(...) substitution spans. An unresolvable command-position word with a sudo/doas mention in the text fails closed.
  • Still allowed: lookup forms (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.
  • The refusal message documents both bypasses: bash(command, allow_sudo=True), or PI_BASH_ALLOW_SUDO=1 honored only when the kernel is started with it (frozen at import). A mid-session os.environ write never unlocks the guard: it triggers one loud warning and is stripped from _child_env() unless the kernel was launched with it.
  • Shape mirrors the fleet guards: frozen bypass snapshot at kernel start, refusal error class, guard call site in bash(), docstring paragraph, changeset fragment.
  • Round-2 red team (7 further threads on 526598b4d, all closed in c8d435875): 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 and case labels 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 the env -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. Four fd -X <flag> claims were dismissed with probe evidence: real fd treats everything after -x/-X as the command line, so it errors on a flag there and never runs sudo.
  • Round-3 red team (5 threads on c8d435875): faketime -m is a boolean flag, so the walk no longer eats the timestamp and faketime -m now sudo id is refused; a brace range past CPython's 4300-digit integer limit fails closed instead of raising out of bash(); sudoku/sudo-report are 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 word su\ (command not found) and then do id under bash 5.3.15, never sudo, so the suggested patch would have added a false refusal (row kept in the non-matching table).
  • Option tables, audited against primary sources while closing that thread: faketime -f is boolean as well, and its options stop at the timestamp (faketime -f '+3d' sudo id was allowed); env -a/--argv0 (coreutils 9.5+), --env0-from (9.12+), and BSD env -P ALTPATH take a value, while the --block/--default/--ignore-signal options are optional-argument and must not eat the utility name (env --ignore-signal sudo id was allowed); strace's required-argument long options (--user, --argv0, --color, --detach-on, ...), ltrace's (--indent, --library, ...), systemd-run -H, and watch -q/-s take a value; and the bundled-short-flag letters were corrected (strace missing E, env missing a/P, faketime still had the boolean f/m, watch missing q/s). Each is probe-verified: the named vector was allowed at c8d435875 and refuses now. Two entry classes stay as they are, deliberately: ltrace -d and systemd-run --drop-in/--kill-who/--wait-timeout are 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.
  • Pre-push review of this delta (bounded, independent) found four more defects in the lines above, all fixed before the push: systemd-run --drop-in/--kill-who/--wait-timeout are not systemd-run options, so those entries swallowed the command word (systemd-run --drop-in sudo id went from refuse to allow); parallel's short operand options -C/-d/-J/-P/-s/-E were missing from the operand table, which let real GNU parallel run parallel -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/sudoku stayed refused. Keying every test on the basename also closed a pre-existing hole: /usr/bin/su* and sud? glob to the tool under real bash and were allowed before, and now refuse. One finding is reported rather than fixed: getopt_long accepts an unambiguous abbreviation of a long option, so strace --verb 5 sudo id still 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.
  • Round-1 red team (16 review threads on e75eabcb8, all closed in 526598b4d): each vector was validated against real bash with a fake sudo/doas shim on PATH before any fix. Closed with regression rows: ANSI-C $'su\x64o' decoding, quote-aware $(...) paren matching, coproc, env -S/-C and wrapper value options (separate, glued, and bundled short flags), xargs option operands, bundled -c flags, 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, including command/command -p/command --, wrapper chains, xargs, and find -exec routes. Review rounds also fixed an O(n^2) brace scan (32k braces: 7.0s -> 13ms) and two over-refusals found by the same review.
  • Deliberately out of scope (listed in the changeset): 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).
  • Finalize pass on the four threads filed after the freeze. Two were confirmed opened bypasses and are fixed: time -p sudo id (the walk judged time's own flag as the command word) and bash --rcfile FILE -c 'sudo id' (the c of a long option was read as a bundled -c, so the real payload was never scanned). Both ran a fake sudo shim 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-pids as required-argument, so modelling them as value-taking matches strace and the suggested change would have opened strace --color always sudo id and its siblings. One stays open: _apply_heredocs is 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) and cmd=(sudo id) (fail-closed: the same text can execute the array) were dismissed with probe evidence, and the POSIX-class spelling [[:lower:]]udo was 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:]]udo and made [[:graph:]]udo refuse 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_ENV startup files (script-file contents, out of scope), and PRIME_AGENT_BASH_SHELL=/usr/bin/python3 (the fixed shell fence fails on its first line, so the command text never executes). hash -p PATH NAME closed in the final pass: the registration is modeled (below).
    The last pass closed the remaining three threads. hash -p pathname name is 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, so hash -p /bin/bash script; script -c 'sudo id' and hash -p /usr/bin/env e; e sudo id are 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, a hash -p entry 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 a hash -p entry (hash -p /usr/bin/sudo env; env id ran 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 -c and 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 as s@(u|x)do with shopt -s extglob on, are accepted as declared residuals in the changeset, each with its probe evidence.

Tests

  • New 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 (BashHandle mocked 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.
  • Every await in the tests carries an explicit timeout; no test executes real sudo/doas.
  • Design-review roundtrip: three probe-validated false negatives (compound-command reserved words ending the scan, command sudo id treated 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 (from prime-agent-runtime/test): 72 tests, OK.

  • Full runtime suite (uv run python -m unittest discover -s test): OK; the two environment-dependent test_bash cases (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 check at 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-range ValueError; against this module the file passes (16 tests, OK). Full runtime suite 337 tests with the same 2 pre-existing environmental test_bash failures, reproduced from a pristine origin/main export of test_bash.py (1 failure, 1 error). npm run check green. Test-line budget: 535 net test additions against 1001 source additions at the merge base with origin/main (passes); measured against the round-2 head c8d435875 the 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_guard 16 tests OK; full runtime suite 337 tests with the same 2 pre-existing environmental failures; npm run check green (test-policy measured against the merge base with origin/main).

  • Known measured cost, accepted for this pass and named as the follow-up: heredoc bodies resolve quadratically, because _apply_heredocs scans 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_heredocs alone; 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 reach sudo or doas are 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, and hash -p registrations, with fail-closed handling for obfuscated spellings and unresolvable expansions. bash(..., allow_sudo=True) and PI_BASH_ALLOW_SUDO=1 only if set at kernel import are the documented bypasses; mid-session env writes are warned, ignored for the guard, and stripped from _child_env(). Direct BashHandle(...) construction runs the same check, and bash() uses one _with_prefix read 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); existing test_bash mocks were updated for the new handle script argument.

Reviewed by Cursor Bugbot for commit 2b0a7bb. Bugbot is set up for automated code reviews on this repo. Configure here.

Note

Refuse sudo/doas privilege escalation in kernel bash tool

  • Adds a shell-aware scanner in bash.py that parses command positions, expansions, redirects, heredocs, aliases, hash registrations, wrapper chains (env, nice, …), and launchers (xargs, parallel, find -exec) to detect any reachable sudo/doas invocation, including obfuscated spellings via globs and brace expansions.
  • Refused commands raise PrivilegeEscalationRefusalError before process creation. Two bypasses exist: a per-call allow_sudo=True on the bash tool, and a frozen startup-environment bypass captured once at module load.
  • Ordinary data mentions of sudo/doas (e.g. inside a non-runner heredoc body) remain allowed; only executable command positions and runner-owned script payloads are refused.
  • Risk: the guard is fail-closed — oversized brace expansions, unresolvable hash -p registrations, 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.

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.
@github-actions

github-actions Bot commented Sep 16, 2026

Copy link
Copy Markdown

Prime Agent performance — completed

PR 2b0a7bb8 compared with main 8969d24c.

Overall: 0 regressed · 0 improved · 42 no clear change.

Metric Main This PR Change
Cold startup 761.6 ms 762.7 ms ≈ +1.1 ms (+0.14%)
Warm startup 624.1 ms 612.3 ms ≈ -11.8 ms (-1.89%)
Installation 12.02 s 7.28 s ≈ -4.74 s (-39.41%)
Compressed release artifacts 73.07 MB 73.12 MB ≈ +0.05 MB (+0.07%)
Installed footprint 595.17 MB 595.70 MB ≈ +0.53 MB (+0.09%)
Idle memory, summed RSS 653.91 MB 654.05 MB ≈ +0.14 MB (+0.02%)

Python runtime

Metric Main This PR Change
Python kernel startup 35.9 ms 36.1 ms ≈ +0.3 ms (+0.72%)
Python cell round trip 0.087 ms 0.093 ms ≈ +0.006 ms (+6.41%)
Empty bash command 2.0 ms 2.3 ms ≈ +0.3 ms (+13.68%)
Bash git status 3.0 ms 3.0 ms ≈ -0.039 ms (-1.31%)
Bash 32 KiB output 2.2 ms 2.3 ms ≈ +0.1 ms (+5.51%)
35 cells / 9 shell calls 28.8 ms 28.4 ms ≈ -0.4 ms (-1.30%)
Python interrupt to done 0.548 ms 0.559 ms ≈ +0.011 ms (+2.05%)
Python state snapshot 10.3 ms 10.6 ms ≈ +0.3 ms (+2.90%)
Python state restore 131.8 ms 139.5 ms ≈ +7.7 ms (+5.82%)
Python idle RSS 21.10 MB 21.43 MB ≈ +0.32 MB (+1.53%)
Python RSS after pandas workload 75.51 MB 75.81 MB ≈ +0.31 MB (+0.41%)

Session transport

Metric Main This PR Change
Full-history transfers per warm session switch 1.00 transfers 1.00 transfers ≈ +0.00 transfers (+0.00%)
Private frame decode, 32 MiB in 8 KiB chunks 15.1 ms 16.1 ms ≈ +1.0 ms (+6.73%)

UI interactions

Metric Main This PR Change
Resume large session (cold) 1,779.5 ms 1,852.0 ms ≈ +72.6 ms (+4.08%)
CPU, resume large session 2,100.0 ms 2,100.0 ms ≈ +4.4e-13 ms (+0.00%)
Switch into large session 1,498.4 ms 1,500.3 ms ≈ +1.8 ms (+0.12%)
CPU, switch into large session 1,650.0 ms 1,600.0 ms ≈ -50.0 ms (-3.03%)
Open agents view from a session 140.7 ms 133.2 ms ≈ -7.6 ms (-5.38%)
CPU, open agents view 70.0 ms 90.0 ms ≈ +20.0 ms (+28.57%)
Full agents roster, many sessions 4.04 s 4.04 s ≈ -0.0029 s (-0.07%)
CPU, full agents roster 0.97 s 0.98 s ≈ +0.01 s (+1.03%)
Open another session from agents view 1,944.2 ms 2,011.0 ms ≈ +66.8 ms (+3.44%)
CPU, open from agents view 1,150.0 ms 1,090.0 ms ≈ -60.0 ms (-5.22%)
Reopen resident large session 200.9 ms 197.7 ms ≈ -3.2 ms (-1.58%)
CPU, reopen resident session 240.0 ms 220.0 ms ≈ -20.0 ms (-8.33%)
Open subagent session at depth 6 17,799.8 ms 17,645.7 ms ≈ -154.1 ms (-0.87%)
CPU, open subagent at depth 6 4,770.0 ms 4,640.0 ms ≈ -130.0 ms (-2.73%)
Open chain parent from agents view 3,185.8 ms 3,160.3 ms ≈ -25.6 ms (-0.80%)
CPU, open chain parent 1,550.0 ms 1,510.0 ms ≈ -40.0 ms (-2.58%)
Scheduled catalog, first request 499.9 ms 473.7 ms ≈ -26.3 ms (-5.25%)
CPU, scheduled catalog 920.0 ms 860.0 ms ≈ -60.0 ms (-6.52%)
Scheduled catalog, repeated request 0.6 ms 0.5 ms ≈ -0.097 ms (-16.21%)
CPU, repeated catalog 0.0 ms 0.0 ms ≈ +0.0 ms (N/A)
Cold worker with three catalog scans 464.2 ms 458.8 ms ≈ -5.4 ms (-1.17%)
CPU, cold worker and scans 500.0 ms 530.0 ms ≈ +30.0 ms (+6.00%)
UI memory after interactions 1,737.22 MB 1,722.99 MB ≈ -14.23 MB (-0.82%)

Sandbox cost: ~$0.1176 — no inference calls.
Run, logs, and downloadable raw results

Methodology and samples

Main resolved at 2026-09-23T00:56:05.989363+00:00. Harness 8969d24c.
Linux x64, 4 vCPU, 8 GB RAM, 20 GB disk; region us.
Image: node:24-bookworm@sha256:be23f54a88d34e8824c741b19b91064094f92c1c97b194144bfc8b50d67258e2.
Stock tools, skills, daemon, and Python bootstrap enabled; fresh homes and a fixed Git fixture.
Onboarding is dismissed; the editor starts without a selected model or submitted prompt.
Medians shown. Arrows require a 20% timing/memory change plus absolute floors and IQR.
These practical noise floors are not a statistical significance test.
Cold means stopped Prime processes; OS filesystem caches are not flushed.
No model requests or credentials. Installation excludes build/setup time.
Installer tarballs use loopback; npm/Python downloads use the network with fresh caches.
Artifact size counts release tarballs; footprint after first use includes registry packages.
MB is decimal. Summed RSS can double-count shared pages; PSS is recorded when available.
Provisioning, setup, and build durations are recorded separately in the raw results.
Kernel probes use the installed JSONL runtime, outside the TUI/TypeScript host.
Per trial: 50 Python cells, 5 calls per shell case, and one 35-cell mix (9 git status calls).
Cell/shell values are batch means; other runtime timings are single operations.
State fixture: a 10,000-row × 8-column integer DataFrame and a 10,000-integer list.
Restore runs in a fresh kernel, including pandas imports; kernel startup is excluded.
Kernel RSS covers the isolated Python process; loaded RSS follows the pandas workload.
Transport benches run node against the prepared source build, outside the installed home.
The switch benchmark drives one warm switch into a 48k-entry session through a real
daemon and counts full-history crossings: streamed replacement snapshots, inline
replacements, and full-history refetch responses.
Frame decode times one 32 MiB private frame, snapshot-chunk header, pushed in
8 KiB chunks; the wire shape of multi-MB frames on the daemon-worker channels.
UI trials use a fresh fixture set: 194 top-level sessions including one ~40 MB transcript,
40 ledger fan-out children, and a 6-deep subagent chain (~46 spawn edges).
Large fixtures hold 1,999 complete triples (~5 MB JSONL); medium 119; subagents 399 each.
Interactions: cold --resume of a large session, warm /resume switch, left-arrow to agents view,
roster settle with many saved sessions, search-and-open of another large session,
reattaching to that resident session, opening the chain parent, and drilling to depth 6.
Readiness is the rendered transcript tail plus a confirmed editor echo.
CPU metrics sum utime+stime across the whole benchmark-user process tree per interaction.
UI memory sums RSS after the interactions; PTY byte counts are in the raw results.
A separate catalog fixture has 2,300 sessions, 2,298 edges, and 13 paused scheduled-job owners.
Catalog timings cover first/repeated reads and cold worker creation under three pending scans.
All expected jobs and owner metadata are checked; worker readiness excludes TUI rendering.
Costs estimate full sandbox lifetimes at configured rates, including setup and build.
Budget target: $1; not a billing cap. Performance changes are informational.
Failed or incomplete execution fails the workflow; saved artifacts remain available.
Each side stops a phase after 2 identical consecutive failures.
Skipped trials are not attempted samples. Warm startup requires a successful cold launch.

Metric Main successful/attempted PR successful/attempted Main spread PR spread
Cold startup 10/10 10/10 IQR 62.5 ms IQR 97.4 ms
Warm startup 10/10 10/10 IQR 65.2 ms IQR 39.2 ms
Installation 3/3 3/3 range 7.05 s range 2.00 s
Compressed release artifacts 1/1 1/1
Installed footprint 1/1 1/1
Idle memory, summed RSS 10/10 10/10 IQR 3.30 MB IQR 1.13 MB
Python kernel startup 10/10 10/10 IQR 6.9 ms IQR 3.4 ms
Python cell round trip 10/10 10/10 IQR 0.010 ms IQR 0.046 ms
Empty bash command 10/10 10/10 IQR 0.3 ms IQR 0.3 ms
Bash git status 10/10 10/10 IQR 0.6 ms IQR 0.3 ms
Bash 32 KiB output 10/10 10/10 IQR 0.3 ms IQR 0.4 ms
35 cells / 9 shell calls 10/10 10/10 IQR 6.3 ms IQR 4.2 ms
Python interrupt to done 10/10 10/10 IQR 0.062 ms IQR 0.049 ms
Python state snapshot 10/10 10/10 IQR 0.7 ms IQR 1.3 ms
Python state restore 10/10 10/10 IQR 19.0 ms IQR 13.5 ms
Python idle RSS 10/10 10/10 IQR 0.12 MB IQR 0.48 MB
Python RSS after pandas workload 10/10 10/10 IQR 0.32 MB IQR 0.28 MB
Full-history transfers per warm session switch 10/10 10/10 IQR 0.00 transfers IQR 0.00 transfers
Private frame decode, 32 MiB in 8 KiB chunks 10/10 10/10 IQR 2.2 ms IQR 2.9 ms
Resume large session (cold) 3/3 3/3 range 332.9 ms range 598.0 ms
CPU, resume large session 3/3 3/3 range 150.0 ms range 250.0 ms
Switch into large session 3/3 3/3 range 212.0 ms range 194.1 ms
CPU, switch into large session 3/3 3/3 range 270.0 ms range 100.0 ms
Open agents view from a session 3/3 3/3 range 5.2 ms range 7.2 ms
CPU, open agents view 3/3 3/3 range 8.9e-13 ms range 40.0 ms
Full agents roster, many sessions 3/3 3/3 range 0.02 s range 0.01 s
CPU, full agents roster 3/3 3/3 range 0.04 s range 0.08 s
Open another session from agents view 3/3 3/3 range 257.8 ms range 222.3 ms
CPU, open from agents view 3/3 3/3 range 120.0 ms range 120.0 ms
Reopen resident large session 3/3 3/3 range 27.3 ms range 4.2 ms
CPU, reopen resident session 3/3 3/3 range 50.0 ms range 30.0 ms
Open subagent session at depth 6 3/3 3/3 range 394.1 ms range 538.0 ms
CPU, open subagent at depth 6 3/3 3/3 range 280.0 ms range 140.0 ms
Open chain parent from agents view 3/3 3/3 range 162.1 ms range 28.9 ms
CPU, open chain parent 3/3 3/3 range 50.0 ms range 100.0 ms
Scheduled catalog, first request 3/3 3/3 range 74.3 ms range 54.1 ms
CPU, scheduled catalog 3/3 3/3 range 90.0 ms range 100.0 ms
Scheduled catalog, repeated request 3/3 3/3 range 0.2 ms range 0.1 ms
CPU, repeated catalog 3/3 3/3 range 0.0 ms range 10.0 ms
Cold worker with three catalog scans 3/3 3/3 range 95.7 ms range 14.2 ms
CPU, cold worker and scans 3/3 3/3 range 150.0 ms range 60.0 ms
UI memory after interactions 3/3 3/3 range 30.70 MB range 16.67 MB

Comment thread prime-agent-runtime/src/rlm/bash.py
Comment thread prime-agent-runtime/src/rlm/bash.py
Comment thread prime-agent-runtime/src/rlm/bash.py
Comment thread prime-agent-runtime/src/rlm/bash.py Outdated
Comment thread prime-agent-runtime/src/rlm/bash.py Outdated
Comment thread prime-agent-runtime/src/rlm/bash.py
Comment thread prime-agent-runtime/src/rlm/bash.py
Comment thread prime-agent-runtime/src/rlm/bash.py Outdated
Comment thread prime-agent-runtime/src/rlm/bash.py Outdated

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stale Bugbot comment from a previous run.

Comment thread prime-agent-runtime/src/rlm/bash.py
Comment thread prime-agent-runtime/src/rlm/bash.py Outdated
Comment thread prime-agent-runtime/src/rlm/bash.py
Comment thread prime-agent-runtime/src/rlm/bash.py Outdated
Comment thread prime-agent-runtime/src/rlm/bash.py
Comment thread prime-agent-runtime/src/rlm/bash.py
Comment thread prime-agent-runtime/src/rlm/bash.py
Comment thread prime-agent-runtime/src/rlm/bash.py Outdated
Comment thread prime-agent-runtime/src/rlm/bash.py Outdated
Comment thread prime-agent-runtime/src/rlm/bash.py
Comment thread prime-agent-runtime/src/rlm/bash.py Outdated
Comment thread prime-agent-runtime/src/rlm/bash.py Outdated

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stale Bugbot comment from a previous run.

Comment thread prime-agent-runtime/src/rlm/bash.py
Comment thread prime-agent-runtime/src/rlm/bash.py Outdated

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stale Bugbot comment from a previous run.

Comment thread prime-agent-runtime/src/rlm/bash.py Outdated
Comment thread prime-agent-runtime/src/rlm/bash.py
Comment thread prime-agent-runtime/src/rlm/bash.py
Comment thread prime-agent-runtime/src/rlm/bash.py
Comment thread prime-agent-runtime/src/rlm/bash.py Outdated
…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.
Comment thread prime-agent-runtime/src/rlm/bash.py
Comment thread prime-agent-runtime/src/rlm/bash.py Outdated
Comment thread prime-agent-runtime/src/rlm/bash.py

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stale Bugbot comment from a previous run.

Comment thread prime-agent-runtime/src/rlm/bash.py
… 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.
Comment thread prime-agent-runtime/src/rlm/bash.py
Comment thread prime-agent-runtime/src/rlm/bash.py
Comment thread prime-agent-runtime/src/rlm/bash.py
`[[: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.

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stale Bugbot comment from a previous run.

Comment thread prime-agent-runtime/src/rlm/bash.py Outdated
Comment thread prime-agent-runtime/src/rlm/bash.py
Comment thread prime-agent-runtime/src/rlm/bash.py
Comment thread prime-agent-runtime/src/rlm/bash.py
Comment thread prime-agent-runtime/src/rlm/bash.py Outdated
`_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.
Comment thread prime-agent-runtime/src/rlm/bash.py Outdated
Comment thread prime-agent-runtime/src/rlm/bash.py
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.
@snimu

snimu commented Sep 22, 2026

Copy link
Copy Markdown
Contributor

_matching_paren here takes 2 args while #2373/#2390/#2415 ship 3-arg versions of the same name — the arity difference turns the silent Python redefinition into a guaranteed TypeError inside whichever sibling guard loses the merge, on any command containing $(...). The same pattern repeats with _HEX_DIGITS (frozenset here vs #2413's str), _ANSI_C_ESCAPES (#2373), _join_line_continuations (#2373), and _HASH_BUILTIN/_hash_registered_command_names (#2390). With #2373 first in the family merge order, the guard-scoped namespace is part of that sequencing step (family precedent: #2395's _fp_* prefix).

[written by prime-agent]

@snimu

snimu commented Sep 22, 2026

Copy link
Copy Markdown
Contributor

_guard_sudo scans _with_prefix(command) while BashHandle re-reads PRIME_AGENT_BASH_COMMAND_PREFIX at spawn, so a mid-call env change makes the scanned text differ from the executed text — the same TOCTOU #2395 and #2413 document and pin against in this family. In-repo precedent: #2413 computes the prefixed text once and passes it as BashHandle(command, script=...). (Credit where due: this PR is the sibling that got _child_env right — the others need to copy it.)

[written by prime-agent]

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stale Bugbot comment from a previous run.

"xargs": _XARGS_OPERAND_OPTIONS,
"parallel": _PARALLEL_OPERAND_OPTIONS,
}
_LAUNCHER_OPERAND_LETTERS: dict[str, str] = {"xargs": _XARGS_OPERAND_LETTERS, "parallel": "jNnLSaI"}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Fix in Cursor Fix in Web

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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)
Fix in Cursor Fix in Web

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"}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 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:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 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

Comment on lines +1666 to +1667
if word.is_data or word.is_redirect:
return None

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 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).

Comment on lines +1751 to +1753
token = words[candidate].value
if words[candidate].is_data or words[candidate].is_redirect:
break

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 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

sethkarten added a commit that referenced this pull request Sep 22, 2026
…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.
sethkarten added a commit that referenced this pull request Sep 22, 2026
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.
@sethkarten

Copy link
Copy Markdown
Contributor Author

Both findings validated against the sibling guard branches and fixed in 6fcf5f8.

1. _matching_paren 2-arg vs the family's 3-arg name — confirmed and fixed by alignment. Verified the divergence: this branch shipped _matching_paren(value, open_index) (previous head, bash.py:876) while #2373 (fix/kernel-dirty-tree-guard), #2390 (cb-fix-chmod), and #2415 (cb-fix-pipe-shell) all ship _matching_paren(command, open_index, end), so whichever sibling loses the family merge would call a 2-arg body with 3 positional arguments — a guaranteed TypeError on the first command containing $(...). Aligned to #2373's exact implementation, copied verbatim together with its _quote_span_end helper (only #2373 carries that helper; #2390/#2415 inline their quote state), and rewrote the four call sites to the 3-arg contract: _tokenize's process substitution, the two _expansion_spans branches (merged into one), and _process_substitution_body. Each passes its region bound and reads the close the family way, then checks whether the returned index actually holds ) so an unterminated span keeps its full remainder live: the 2-arg body returned len(value) while the 3-arg body returns end - 1, and a naive bound would have dropped the last character — echo $(sudo would have scanned only su. The remaining names from the comment are not arity-divergent across the branches (in checks behave identically on frozenset and str _HEX_DIGITS, and _ANSI_C_ESCAPES/_join_line_continuations/_HASH_BUILTIN/_hash_registered_command_names are same-arity), so the guaranteed-TypeError harm was specific to _matching_paren, which is what this commit removes.

Regression coverage: test_matching_paren_keeps_the_three_arg_family_contract (calls the 3-arg form directly; raises TypeError on the previous head), plus new sample rows echo $(sudo id, echo <(sudo id, bash -c "$(sudo id (refused) and echo $(date (allowed), pinning that unterminated spans still scan their remainder under the new close convention.

2. _guard_sudo scans _with_prefix(command) while BashHandle re-reads PRIME_AGENT_BASH_COMMAND_PREFIX — confirmed and fixed with the #2413 shape. Verified: bash() called _guard_sudo(command, allow_sudo) -> _sudo_violation(_with_prefix(command)) (prefix read 1), then BashHandle.__init__ called _with_prefix(command) again to build the spawn script (prefix read 2), so an environment change between the two reads made the scanned text differ from the executed text. Fixed: bash() computes script = _with_prefix(command) once, _guard_sudo(script, ...) scans exactly that text, and BashHandle(command, script=...) runs it — the same script= parameter #2413 ships. The handle spawns self._script instead of recomputing, on both the POSIX status-script and the Windows path. Direct BashHandle(...) construction (the class is exported and used by tests) carried no guard at all before, so __init__ now guards a directly constructed handle on the same one read that supplies its script — the class is no longer a way around the guard.

Regression coverage: test_one_prefix_read_feeds_the_scan_and_the_spawn (spy on _with_prefix; exactly one read per call — the previous head read twice), test_scanned_prefix_text_is_the_spawned_script (a sudo-bearing prefix is refused before spawn; a benign prefix reaches the handle as the spawned script), test_direct_handle_construction_is_guarded (BashHandle("sudo id") now raises PrivilegeEscalationRefusalError).

Validation in the worktree: npm run check passes (biome, tsgo, test-policy, installer, push-guard, browser-smoke), the commit keeps the test-line budget at 1:1 against the pre-commit head, the runtime suite runs 367 tests with no new failures, and all 21 sudo-guard tests pass. Existing BashHandle.__init__ capturing wrappers in test_bash.py were updated for the new optional parameter.

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stale Bugbot comment from a previous run.

# script: the class must not be a way around the guard.
if script is None:
script = _with_prefix(command)
_guard_sudo(script, False)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 6fcf5f8. Configure here.

heredoc = operator.startswith("<<")
words.append(
_Word(
value=command[index:after] + target,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 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(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 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.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 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.

sethkarten added a commit that referenced this pull request Sep 22, 2026
… 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.
@snimu

snimu commented Sep 22, 2026

Copy link
Copy Markdown
Contributor

_join_line_continuations (bash.py:528) is the one remaining blocker after re-review — everything else in this PR checks out (the _matching_paren 3-arg alignment is byte-identical to #2373's canonical and the prefix TOCTOU pins are real).

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: true # \ + newline + sudo id — real bash executes the sudo line, but the joiner fuses it into one comment line, the tokenizer at bash.py:740 skips it, and the guard allows the command (verified against a live shell).

Adopting #2373's comment-aware body verbatim (the same alignment already done for _matching_paren) plus a pin for that vector closes both at once.

[written by prime-agent]

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stale Bugbot comment from a previous run.

double = not double
out.append(char)
index += 1
return "".join(out)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Fix in Cursor Fix in Web

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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 44f501f. Configure here.

flush()
if char == "\n":
segment_start = True
index += 1

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 44f501f. Configure here.

@sethkarten
sethkarten disabled auto-merge September 22, 2026 23:43
@sethkarten
sethkarten enabled auto-merge (squash) September 22, 2026 23:43

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stale Bugbot comment from a previous run.

double = not double
out.append(char)
index += 1
return "".join(out)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit fd88b1d. Configure here.

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stale Bugbot comment from a previous run.

double = not double
out.append(char)
index += 1
return "".join(out)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 612ac63. Configure here.

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stale Bugbot comment from a previous run.

while position < len(following):
word = words[following[position]]
if word.is_data or word.is_redirect:
return None

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 &lt; file sudo …, timeout &gt;out 5 sudo …, and find . -exec &gt;out sudo … therefore run sudo/doas without a refusal.

Additional Locations (2)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 4d14983. Configure here.

violation = _scan_text(body, depth + 1, parent_mentions_sudo)
if violation:
return violation
return None

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 4d14983. Configure here.

start += 1
while start < len(words) and _is_flag_word(words[start]):
start += 1
continue

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 4d14983. Configure here.

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

There are 24 total unresolved issues (including 23 from previous reviews).

Fix All in Cursor

❌ 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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 2b0a7bb. Configure here.

@sethkarten
sethkarten disabled auto-merge September 23, 2026 01:41
@sethkarten
sethkarten enabled auto-merge (squash) September 23, 2026 01:41
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants