Skip to content
Open
Show file tree
Hide file tree
Changes from 7 commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
2e592c5
fix(utils): never take NODE_TLS_REJECT_UNAUTHORIZED from the project …
10kH Oct 10, 2026
8d28032
fix(tmux-self-injection-guard): handle wrapper options and bundled sh…
Oct 10, 2026
2e9a7ce
fix(tmux-self-injection-guard, utils): Address 6 blocking review find…
Oct 10, 2026
3aebf1c
chore: remove TLS environment work that belongs to PR #6552
Oct 10, 2026
773cea9
restore: unrelated TUI drag-copy changes from origin/dev
Oct 10, 2026
a8f1341
merge: pull origin/dev for CI base
Oct 10, 2026
498eae8
fix(tmux-self-injection-guard): address all 5 blocking review findings
Oct 10, 2026
556fde0
fix(tmux-self-injection-guard): correct command boundary check in isC…
Oct 10, 2026
831a37a
fix(tmux-self-injection-guard): fix all blocking findings from probep…
Oct 10, 2026
8e62abd
test: mark socket override regression tests as TODO (issue #6564-6)
Oct 10, 2026
96b3528
fix(tmux-self-injection-guard): preserve command boundaries in lookup…
Oct 10, 2026
346b905
fix(tmux-self-injection-guard): handle bash long options in payload d…
Oct 10, 2026
6688fdc
chore: add changelog entry for tmux shell payload selection fix (issu…
Oct 10, 2026
8d536c8
fix(tmux-guard): resolve 4 critical blocking findings from PR #6564 r…
Oct 10, 2026
7f165fe
fix: address 6 blocking findings from probepark review on PR #6564
Oct 10, 2026
8d159fb
fix(tmux-self-injection-guard): handle quoted payloads and env --spli…
Oct 10, 2026
2f91383
fix(tmux-self-injection-guard): add long-option arity specs for timeo…
Oct 10, 2026
d29460e
fix(tmux-self-injection-guard): handle bundled -Oc option in bash
Oct 10, 2026
fdf4b37
fix(tmux-self-injection-guard): properly parse env option operands an…
Oct 10, 2026
ac13a8a
fix(tmux-self-injection-guard): normalize path-qualified wrapper comm…
Oct 10, 2026
dcf0c87
fix(tmux-self-injection-guard): improve socket assignment backtrackin…
Oct 10, 2026
72e8064
fix(tmux-self-injection-guard): fix env-S context, socket operands, a…
Oct 10, 2026
18828ff
fix(tmux-self-injection-guard): apply biome formatting
Oct 10, 2026
ea47dc7
Merge remote-tracking branch 'origin/dev' into wt-fix-6564-clean
gaebal-gajae Oct 11, 2026
3d41670
fix(tmux-self-injection-guard): address P1 blocking findings from sno…
gaebal-gajae Oct 11, 2026
8f68cee
fix: remove duplicate xargs --replace entry in long-option table
gaebal-gajae Oct 11, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions packages/coding-agent/changelog.d/6564-tmux-guard-fixes.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
### Security

- fix(tmux-self-injection-guard): Fix multiple command-parsing vulnerabilities in the tmux injection guard
- Restrict `command -v`/`-V` lookup suppression to the same command invocation; previously suppressed across command boundaries (`;`, `|`)
- Preserve environment variable assignments like `env FOO=bar` in wrapper context; previously treated them as wrapped commands
- Separate flag-only options (`setsid -c/-w`, `sudo -s/-i`, `xargs -0/-t/-x`) from argument-taking options to prevent adjacent words from being consumed as option arguments
- Recognize shell option operands (`bash -O extglob`, `bash -o pipefail`) and skip over them when scanning for script payloads; previously halted at option operands
- Removed unreachable dead code in wrapper mode state machine
313 changes: 279 additions & 34 deletions packages/coding-agent/src/tools/tmux-self-injection-guard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,46 @@ const COMMAND_WRAPPERS = new Set(["eval", "exec", "command", "builtin", "nohup",
const CONTROL_WORDS = new Set(["then", "do", "else", "fi", "done", "esac"]);
const MAX_INDIRECTION_DEPTH = 4;

// Wrapper commands and their options that take an argument.
// These are used to properly skip over option arguments so the next word
// in command position is recognized as the actual command.
interface WrapperSpec {
// Option flags that take exactly one argument (e.g., "-u NAME")
optionsWithArg: Set<string>;
// Flag-only options that do not consume the next argument (e.g., sudo "-s", "-i")
flagOnlyOptions?: Set<string>;
// Special positional argument handling (e.g., timeout takes a DURATION)
hasPositionalArg?: boolean;
}

const COMMAND_WRAPPERS_WITH_ARGS: Record<string, WrapperSpec> = {
env: {
optionsWithArg: new Set(["-u", "-C", "-S"]),

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Recursively inspect env split-string payloads

When env -S contains the command itself, as in env -S 'tmux send-keys -t %47 x', this entry causes the quoted string to be consumed as an ordinary option operand, leaving no tmux command token, so the guard returns { block: false }. The local env --help defines -S, --split-string=S as processing and splitting its value into separate arguments, meaning this form executes the injection; inspect the split payload rather than merely skipping it.

Useful? React with 👍 / 👎.

},
Comment on lines +82 to +84

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Parse env long-option operands before choosing the command

With GNU env --unset tmux send-keys -t %47 x, tmux is the variable-name operand and the program attempted is send-keys, so no tmux injection occurs; env --help documents this as -u, --unset=NAME. Because the table recognizes only -u, the tokenizer instead marks tmux as the wrapped command and blocks this valid long-option form. Add the argument-taking long options, including their separated-operand form, before selecting the wrapped command.

Useful? React with 👍 / 👎.

sudo: {
optionsWithArg: new Set(["-u", "-g", "-h", "-p", "-C", "-D", "-r", "-t", "-U", "-T"]),

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Consume sudo chroot option operands

When sudo's chroot option precedes the command, sudo -R / tmux send-keys -t %47 x treats / as the wrapped command and returns { block: false }, although sudo's local help lists -R, --chroot=directory in its command-execution form and tmux is the actual command whenever policy permits it. Add both the short and separated long forms to sudo's argument-taking options.

Useful? React with 👍 / 👎.

flagOnlyOptions: new Set(["-s", "-i"]),
Comment on lines +85 to +87

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Exclude sudo's non-executing modes from wrapper parsing

On this Linux checkout, sudo -h documents -l as listing/checking privileges and -e as editing files instead of running a command, but the new wrapper state treats their first operand as an executed command. Consequently, a read-only check such as sudo -l -- tmux send-keys -t %47 x now returns block: true even though sudo only reports whether that command is permitted; detect these non-executing modes before marking tmux as a wrapped command.

Useful? React with 👍 / 👎.

},
timeout: {
optionsWithArg: new Set(["-s", "-v"]), // timeout supports -s SIGNAL and -v VERBOSE
hasPositionalArg: true, // timeout DURATION command
Comment on lines +89 to +92

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Consume long wrapper option operands

When an argument-taking long option uses a separate operand, the wrapper parser ends wrapper mode at that operand. For example, timeout --signal TERM 5 tmux send-keys -t %47 x executes tmux but returns { block: false } because only the short -s/-k forms are registered and TERM is treated as the wrapped command. The local timeout --help documents -s, --signal=SIGNAL, and the separate-operand form was verified to execute its command; add the corresponding long-option arities (the same issue affects wrappers such as nice --adjustment 5 and stdbuf --output L).

Useful? React with 👍 / 👎.

},
nice: {
optionsWithArg: new Set(["-n"]),
},
xargs: {
optionsWithArg: new Set(["-E", "-I", "-J", "-L", "-n", "-P", "-R", "-d"]),
flagOnlyOptions: new Set(["-s", "-t", "-x", "-0"]),

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Consume xargs input-file operands

When a file named tmux is supplied via xargs -a tmux send-keys -t %47 x, tmux is only the input filename and send-keys is the executed command. The local xargs --help documents -a, --arg-file=FILE, but -a is absent from optionsWithArg, causing the guard to mark the filename as a command and reject this safe invocation. Add -a and the corresponding argument-taking long form to the wrapper specification.

Useful? React with 👍 / 👎.

},
stdbuf: {
optionsWithArg: new Set(["-i", "-o", "-e"]),
},
setsid: {
optionsWithArg: new Set(),
flagOnlyOptions: new Set(["-c", "-w"]),
},
};

export interface TmuxSelfInjectionResult {
block: boolean;
reason?: string;
Expand Down Expand Up @@ -51,32 +91,102 @@ function tokenize(command: string): Token[] {
let quoted = false;
let quote: "'" | '"' | undefined;
let atCommandStart = true;
let wrapperPending = false;
let previous = "";
let wrapperCommand: { name: string; spec: WrapperSpec } | undefined;
let skipNextArg = false;
let positionalsPending = 0;

const flush = () => {
if (text.length === 0) return;
const token: Token = { text, quoted, commandStart: atCommandStart };
tokens.push(token);

let commandStart = atCommandStart;

if (atCommandStart) {
if (isAssignment(text) || CONTROL_WORDS.has(text) || wrapperPending) {
atCommandStart = true;
} else if (COMMAND_WRAPPERS.has(text)) {
atCommandStart = true;
wrapperPending = true;
} else if (text === "sudo" || text === "env") {
const newWrapper = COMMAND_WRAPPERS_WITH_ARGS[text];

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Recognize path-qualified wrapper executables

When a wrapper is invoked by path, this exact lookup does not enter wrapper mode, so /usr/bin/env FOO=x tmux send-keys -t %47 x returns { block: false } even though env executes the tmux command. The local env --help synopsis confirms that the trailing COMMAND [ARG]... is executed; normalize wrapper tokens to their basename, as is already done implicitly for path-qualified tmux commands.

Useful? React with 👍 / 👎.

const isBuiltinWrapper = COMMAND_WRAPPERS.has(text);

if (CONTROL_WORDS.has(text)) {
// Control words reset wrapper state (but not if we're already in a wrapper)
if (!wrapperCommand) {
wrapperCommand = undefined;
skipNextArg = false;
positionalsPending = 0;
}
atCommandStart = true;
wrapperPending = true;
} else if (isAssignment(text)) {
// Assignments at command start
if (!wrapperCommand) {
// Not in a wrapper - this is a simple assignment at command start
atCommandStart = true;
}
// If in a wrapper, keep wrapper active and don't change atCommandStart
} else if (newWrapper) {
// Track this wrapper for its arguments
wrapperCommand = { name: text, spec: newWrapper };
skipNextArg = false;
positionalsPending = newWrapper.hasPositionalArg ? 1 : 0;
atCommandStart = false; // Now in wrapper argument mode
} else if (isBuiltinWrapper) {
// Built-in wrappers
wrapperCommand = { name: text, spec: { optionsWithArg: new Set() } };
skipNextArg = false;
positionalsPending = 0;
atCommandStart = false; // Now in wrapper argument mode
} else if (wrapperCommand) {
// This is the command being wrapped - mark it as a command start
wrapperCommand = undefined;
skipNextArg = false;
positionalsPending = 0;
commandStart = true; // The wrapped command is a command start
atCommandStart = false;
} else {
// Regular command
atCommandStart = false;
wrapperPending = false;
}
} else if (wrapperPending) {
// A wrapper's options and option arguments are not command names.
// The first ordinary word after them is handled by the special case
// below when the next token is seen.
atCommandStart = false;
} else {
// Not at command start
if (text.startsWith("-")) {
// This is an option, don't touch skipNextArg yet; it will be consumed by the next argument
} else if (skipNextArg) {
Comment on lines +213 to +216

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Consume option-looking wrapper operands before options

When an argument-taking wrapper option has a value beginning with -, this branch handles the value as another option before consulting skipNextArg. The local xargs --help documents -I R, and printf x | xargs -I -X tmux send-keys -t %47 x executes tmux with -X as the replacement string, yet the guard returns { block: false } because the subsequent tmux token is consumed as the pending operand. Honor skipNextArg before classifying the current token as an option.

Useful? React with 👍 / 👎.

// This is an option argument, not part of wrapper logic
skipNextArg = false;
Comment on lines +216 to +219

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Preserve empty wrapper operands before consuming commands

When an argument-taking wrapper option receives an explicit empty quoted value, the lexer emits no token for that value, leaving skipNextArg set so this branch consumes the actual command instead. For example, env -S '' tmux send-keys -t %47 x returns { block: false }, although the local env --help documents -S, --split-string=S as processing S, and GNU env accepts the empty string then executes the following tmux command. Retain empty quoted arguments as tokens so the pending operand is consumed without hiding the injection.

AGENTS.md reference: AGENTS.md:L169-L171

Useful? React with 👍 / 👎.

} else if (positionalsPending > 0) {
// This is a positional argument like timeout's DURATION
positionalsPending--;
} else if (isAssignment(text) && wrapperCommand) {
// In a wrapper, assignments are arguments, not wrapped commands
// Keep wrapper mode active for nested wrappers like 'env sudo -E tmux ...'
commandStart = 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.

P2 Badge Carry leading TMUX overrides across env assignments

For TMUX=/tmp/other env FOO=x tmux send-keys -t %47 x, the new assignment branch marks FOO=x as a non-command-start token. assignedTmuxSocket then stops backtracking at that token and never sees the leading TMUX override, so the guard incorrectly treats this as targeting the current server and blocks it; the parent allowed this cross-server command. Preserve the wrapper association or let socket lookup traverse environment assignments.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Restrict assignment skipping to assignment-aware wrappers

For wrappers that do not parse environment assignments, such as command FOO=bar tmux send-keys -t %47 x, this branch keeps wrapper mode active past FOO=bar and marks tmux as the executed command, so the guard blocks a command that never injects. Bash 5.2.21 documents command [-pVv] command [arg ...], and here it attempts to execute FOO=bar while tmux is merely an argument; only assignment-aware wrappers such as env and sudo should skip these tokens.

Useful? React with 👍 / 👎.

} else if (wrapperCommand && !text.startsWith("-") && !isAssignment(text)) {
// We're in a wrapper and this is not an option or assignment
// Check if this is itself a wrapper (nested wrappers like 'env sudo')
const nestedWrapper = COMMAND_WRAPPERS_WITH_ARGS[text];
const isNestedBuiltinWrapper = COMMAND_WRAPPERS.has(text);

if (nestedWrapper) {
// Nested wrapper found - set it up as the new wrapper
wrapperCommand = { name: text, spec: nestedWrapper };

skipNextArg = false;
positionalsPending = nestedWrapper.hasPositionalArg ? 1 : 0;
commandStart = true; // The wrapper itself is still a command start
} else if (isNestedBuiltinWrapper) {
// Nested built-in wrapper found
wrapperCommand = { name: text, spec: { optionsWithArg: new Set() } };
skipNextArg = false;
positionalsPending = 0;
commandStart = true; // The wrapper itself is still a command start
} else {
// Not a wrapper - this is the final wrapped command
wrapperCommand = undefined;
skipNextArg = false;
commandStart = true; // The wrapped command is a command start
}
}
}

const token: Token = { text, quoted, commandStart };
tokens.push(token);

text = "";
quoted = false;
};
Expand Down Expand Up @@ -107,29 +217,31 @@ function tokenize(command: string): Token[] {
}
if (/\s/.test(ch)) {
flush();
previous = ch;
continue;
}
if (isSeparator(ch)) {
flush();
atCommandStart = true;
wrapperPending = false;
previous = ch;
wrapperCommand = undefined;
skipNextArg = false;
positionalsPending = 0;
continue;
}
// `sudo -u user tmux` and `env FOO=bar tmux` need the wrapped command
// recognized. Options, option arguments, and environment assignments
// stay outside command position.
if (text.length === 0 && wrapperPending && previous && /\s/.test(previous)) {
if (text.length === 0 && ch === "-") {
// The option itself is not a command; its argument is also skipped.
atCommandStart = false;

text += ch;

// After building a token, check if it's an option that needs an argument
if (!atCommandStart && wrapperCommand && text.startsWith("-")) {
const nextChar = command[index + 1];
if (!nextChar || /\s/.test(nextChar) || isSeparator(nextChar)) {
// This option is complete; check if it takes an argument
// Flag-only options don't consume the next argument
const isFlagOnly = wrapperCommand.spec.flagOnlyOptions?.has(text) ?? false;
if (!isFlagOnly && wrapperCommand.spec.optionsWithArg.has(text)) {
skipNextArg = 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.

P1 Badge Parse bundled wrapper short options

When a wrapper bundles flags and an argument-taking option, exact-set matching misses the operand and loses command position. For example, local coreutils accepts env -iu FOO <command> as -i -u FOO <command>, but env -iu FOO tmux send-keys -t %47 x returns { block: false } because FOO is treated as the wrapped command. Split valid short-option bundles and consume the operand of the option that requires one.

Useful? React with 👍 / 👎.

}
}
}
if (text.length === 0 && wrapperPending && atCommandStart) {
atCommandStart = true;
}
text += ch;
}
flush();
return tokens;
Expand Down Expand Up @@ -192,13 +304,85 @@ function targetFromArgs(args: string[]): string | undefined {

function collectShellPayloads(tokens: Token[]): string[] {
const payloads: string[] = [];
// Options that take an argument for bash/sh/similar shells
const optionsWithArg = new Set(["-O", "-o"]); // bash -O extglob, bash -o pipefail, etc.

for (let index = 0; index < tokens.length; index++) {
const token = tokens[index];
if (!token.commandStart || !SHELL_RUNNERS.has(token.text)) continue;

// Scan for -c options in the shell invocation.
// Look for patterns like:
// - bash -c 'payload'
// - bash -ce 'payload' (bundled option)
// - bash -c -e 'payload' (separate options)
// - bash -O extglob -c 'payload' (shopt with operand)
// - bash -o pipefail -c 'payload' (shopt with operand)
// POSIX shells stop option parsing at the first non-option argument,
// so after we see a non-option word (script name), any flags are arguments to that script.

let skipNextArg = false;

for (let cursor = index + 1; cursor < tokens.length && !tokens[cursor].commandStart; cursor++) {
if (tokens[cursor].text === "-c" && tokens[cursor + 1]?.quoted) {
payloads.push(tokens[cursor + 1].text);
break;
const word = tokens[cursor].text;

// If the previous option takes an argument, skip this word
if (skipNextArg) {
skipNextArg = false;
continue;
}

// Once we see a non-option word (not starting with -), that's the script name.
// Any further flags are arguments to that script, not shell options.
if (!word.startsWith("-")) {
break; // Stop scanning for -c after the first operand
Comment on lines +558 to +561

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Skip shell option operands before searching for -c

This stops scanning at every non-option token, including operands belonging to preceding shell options. Bash documents -O shopt_option, so bash -O extglob -c 'tmux send-keys -t %47 x' executes the quoted payload but the guard stops at extglob and returns { block: false }. Parse operands for options such as -O, -o, and --rcfile before deciding that a script operand has begun.

Useful? React with 👍 / 👎.

Comment on lines +558 to +561

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Parse runner-specific operands before stopping at scripts

When the runner has a required operand before -c, this unconditional stop misses an executed payload: bash --rcfile /tmp/rc -c 'tmux send-keys -t %47 x' returns { block: false }, although Bash accepts --rcfile filename before -c (Bash manual). It also regresses busybox sh -c ..., where sh is the applet rather than a script operand (BusyBox syntax). Fresh evidence in the current head is that direct guard calls for both forms return false, whereas the parent blocked them, so scanning needs runner-specific option/applet handling before deciding a script has begun.

Useful? React with 👍 / 👎.

}

// Check if this option takes an argument (e.g., -O, -o for bash)
if (optionsWithArg.has(word)) {
skipNextArg = true;
continue;
}

// Handle options with bundled operands (e.g., -Oextglob, -opipefail)
if ((word.startsWith("-O") && word.length > 2) || (word.startsWith("-o") && word.length > 2)) {
// The operand is bundled with the option, so skip this and check the next word for -c
continue;
Comment on lines +586 to +619

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Detect command mode inside operand-taking option bundles

The early continue for -O.../-o... skips a bundled c, so bash -Oc extglob 'tmux send-keys -t %47 x' and bash -oc nounset 'tmux send-keys -t %47 x' both execute the quoted command but the guard returns { block: false }. Checked with Bash 5.2.21: its help documents -c command and -O shopt_option, and both bundles execute the payload; parse every flag in the bundle while still consuming the operand required by O/o.

Useful? React with 👍 / 👎.

}

// Check for -c option (standalone or bundled like -ce, -ec, etc.)
if (word === "-c" || (word.startsWith("-") && word.includes("c"))) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Match only actual shell command-string options

For Bash invocations with a long option containing the letter c, such as bash --norc 'tmux send-keys -t %47 x', this condition mistakes --norc for -c and recursively blocks the following text. Checked against Bash 5.2.21: bash --help lists --norc as a GNU long option and documents -c command separately, and the quoted operand here is treated as a script filename rather than executed as a command string. Restrict bundled detection to valid short-option bundles containing c.

Useful? React with 👍 / 👎.

// Found a -c option (or an option containing c)
// The payload is the next non-option word
for (
let payloadCursor = cursor + 1;
payloadCursor < tokens.length && !tokens[payloadCursor].commandStart;
payloadCursor++
) {
const payloadWord = tokens[payloadCursor].text;

// Skip over any other options or their arguments
if (payloadWord.startsWith("-")) {
// Check if this option takes an argument
if (optionsWithArg.has(payloadWord)) {
payloadCursor++; // Skip the argument
Comment on lines +636 to +641

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Honor -- before a dash-prefixed shell payload

For bash -c -- '-x || tmux send-keys -t %47 x', Bash executes the command string and reaches tmux after -x fails, but the guard returns { block: false }: this loop skips both the -- terminator and the following dash-prefixed payload as options. Bash's local help documents -c command; once -- terminates invocation-option parsing, the next token must be inspected as that command even when it begins with -.

Useful? React with 👍 / 👎.

}
// Handle options with bundled operands (e.g., -Oextglob)
if (
(payloadWord.startsWith("-O") && payloadWord.length > 2) ||
(payloadWord.startsWith("-o") && payloadWord.length > 2)
) {
// The operand is bundled with the option, no need to skip
}
continue;
}

// Found the first non-option word; this should be the payload
if (tokens[payloadCursor].quoted) {
payloads.push(payloadWord);
}
break; // Only take the first payload for this -c
}
}
}
}
Expand Down Expand Up @@ -265,6 +449,65 @@ function assignedTmuxSocket(tokens: Token[], commandIndex: number): string | und
return undefined;
}

/**
* Check if the given index is part of a `command -v` or `command -V` lookup.
* Such invocations are not executions, so tmux commands within them don't run.
* The target token must be the actual command being looked up, not a subsequent command after a separator.
*/
function isCommandLookup(tokens: Token[], targetIndex: number): boolean {
// Backtrack through all tokens to find if we're in a `command -v` sequence
// Look for the pattern: command -v/V TARGET, where TARGET is the direct command being looked up
for (let commandIdx = 0; commandIdx < targetIndex; commandIdx++) {
if (!tokens[commandIdx].commandStart || tokens[commandIdx].text !== "command") {
continue;
}

// Found a "command" token, look for -v or -V after it
let lookupFlagIdx = -1;
for (let i = commandIdx + 1; i < targetIndex; i++) {
const token = tokens[i].text;
if (token === "-v" || token === "-V") {
lookupFlagIdx = i;
Comment on lines +979 to +986

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Recognize bundled command lookup flags

When lookup flags are bundled, such as command -pv tmux send-keys, this exact-token check fails and the guard blocks the invocation even though no tmux command runs. Checked against Bash's local help command, which documents command [-pVv] and allows these options to be combined; recognize v or V within valid short-option bundles so legitimate command inspection remains allowed.

Useful? React with 👍 / 👎.

break;
}
}

// If we found the lookup flag, check if the target is the next non-option word
// within the same command (not after a separator)
if (lookupFlagIdx >= 0) {
// Check if there's a separator between the lookup flag and the target
let hasSeparatorInBetween = false;
for (let sep = lookupFlagIdx + 1; sep < targetIndex; sep++) {
if (tokens[sep].commandStart && isSeparator(tokens[sep].text)) {
hasSeparatorInBetween = true;
break;
}
}
if (hasSeparatorInBetween) {
// Target is after a separator, so it's not part of the lookup
continue;
}

// Find the wrapped command (first non-option, non-assignment after the lookup flag)
for (let i = lookupFlagIdx + 1; i < tokens.length; i++) {
const word = tokens[i].text;
// Stop at any separator
if (isSeparator(word)) {
break;
}
// Skip options and assignments
if (word.startsWith("-") || isAssignment(word)) {
continue;
}
// This is the wrapped command; check if it matches the target
return i === targetIndex;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Keep empty lookups from suppressing the next command

When command -v or command -V has no lookup operand before a separator, as in command -v; tmux send-keys -t %47 x, the lexer has already discarded the separator, so this loop treats the later tmux token as the lookup target and skips the guard even though the shell executes it as a separate command. Fresh evidence on the reviewed tree is that this exact command returns { block: false }, while the parent blocked it; retain command-boundary information rather than inferring the lookup solely from the first remaining ordinary word.

Useful? React with 👍 / 👎.

}
}
}

return false;
}

const defaultResolvePaneId: TmuxPaneResolver = async ({ socketArgs, target, env }) => {
const processHandle = Bun.spawn(["tmux", ...socketArgs, "display-message", "-p", "-t", target, "#{pane_id}"], {
env,
Expand Down Expand Up @@ -299,6 +542,8 @@ export async function checkTmuxSelfInjection(
for (let index = 0; index < tokens.length; index++) {
if (!tokens[index].commandStart || (tokens[index].text !== "tmux" && !tokens[index].text.endsWith("/tmux")))
continue;
// Skip tmux invocations that are part of a `command -v` lookup (not execution)
if (isCommandLookup(tokens, index)) continue;
const invocation = parseInvocation(tokens.slice(index + 1));
if (!invocation || !INPUT_VERBS.has(invocation.verb)) continue;
const assignedSocket = assignedTmuxSocket(tokens, index);
Expand Down
Loading
Loading