(context: ValidationContext)
| 1917 | } |
| 1918 | |
| 1919 | function validateMidWordHash(context: ValidationContext): PermissionResult { |
| 1920 | const { unquotedKeepQuoteChars } = context |
| 1921 | // Match # preceded by a non-whitespace character (mid-word hash). |
| 1922 | // shell-quote treats mid-word # as comment-start but bash treats it as a |
| 1923 | // literal character, creating a parser differential. |
| 1924 | // |
| 1925 | // Uses unquotedKeepQuoteChars (which preserves quote delimiters but strips |
| 1926 | // quoted content) to catch quote-adjacent # like 'x'# — fullyUnquotedPreStrip |
| 1927 | // would strip both quotes and content, turning 'x'# into just # (word-start). |
| 1928 | // |
| 1929 | // SECURITY: Also check the CONTINUATION-JOINED version. The context is built |
| 1930 | // from the original command (pre-continuation-join). For `foo\<NL>#bar`, |
| 1931 | // pre-join the `#` is preceded by `\n` (whitespace → `/\S#/` doesn't match), |
| 1932 | // but post-join it's preceded by `o` (non-whitespace → matches). shell-quote |
| 1933 | // operates on the post-join text (line continuations are joined in |
| 1934 | // splitCommand), so the parser differential manifests on the joined text. |
| 1935 | // While not directly exploitable (the `#...` fragment still prompts as its |
| 1936 | // own subcommand), this is a defense-in-depth gap — shell-quote would drop |
| 1937 | // post-`#` content from path extraction. |
| 1938 | // |
| 1939 | // Exclude ${# which is bash string-length syntax (e.g., ${#var}). |
| 1940 | // Note: the lookbehind must be placed immediately before # (not before \S) |
| 1941 | // so that it checks the correct 2-char window. |
| 1942 | const joined = unquotedKeepQuoteChars.replace(/\\+\n/g, match => { |
| 1943 | const backslashCount = match.length - 1 |
| 1944 | return backslashCount % 2 === 1 ? '\\'.repeat(backslashCount - 1) : match |
| 1945 | }) |
| 1946 | if ( |
| 1947 | // eslint-disable-next-line custom-rules/no-lookbehind-regex -- .test() with atom search: fast when # absent |
| 1948 | /\S(?<!\$\{)#/.test(unquotedKeepQuoteChars) || |
| 1949 | // eslint-disable-next-line custom-rules/no-lookbehind-regex -- same as above |
| 1950 | /\S(?<!\$\{)#/.test(joined) |
| 1951 | ) { |
| 1952 | logEvent('tengu_bash_security_check_triggered', { |
| 1953 | checkId: BASH_SECURITY_CHECK_IDS.MID_WORD_HASH, |
| 1954 | }) |
| 1955 | return { |
| 1956 | behavior: 'ask', |
| 1957 | message: |
| 1958 | 'Command contains mid-word # which is parsed differently by shell-quote vs bash', |
| 1959 | } |
| 1960 | } |
| 1961 | return { behavior: 'passthrough', message: 'No mid-word hash' } |
| 1962 | } |
| 1963 | |
| 1964 | /** |
| 1965 | * Detects when a `#` comment contains quote characters that would desync |
nothing calls this directly
no test coverage detected