From 8255177d84948c30af49fb22ee60faecac086429 Mon Sep 17 00:00:00 2001 From: AgentSeal Date: Fri, 10 Jul 2026 15:31:46 +0200 Subject: [PATCH] fix(bash): attribute wrapped commands to the real tool, not the wrapper extractBashCommands recorded the wrapper (sudo, npx, rtk, and friends) instead of the command it delegates to, so any agent that prefixes its shell calls collapsed its whole bash breakdown into one meaningless bucket and the optimize detectors lost the actual tool. Skip a known set of command wrappers when a real command follows, and interleave that skip with the existing VAR=value env-assignment skip so forms like 'sudo NODE_ENV=prod node x' resolve to the real tool. A wrapper followed by a flag or a quoted token is kept as-is so we never emit a garbage key. Closes #657 --- src/bash-utils.ts | 20 +++++++++++++++++++- tests/bash-commands.test.ts | 37 +++++++++++++++++++++++++++++++++++++ 2 files changed, 56 insertions(+), 1 deletion(-) diff --git a/src/bash-utils.ts b/src/bash-utils.ts index 2e5fe0db..06a4f6df 100644 --- a/src/bash-utils.ts +++ b/src/bash-utils.ts @@ -5,6 +5,14 @@ function stripQuotedStrings(command: string): string { return command.replace(/"[^"]*"|'[^']*'/g, match => ' '.repeat(match.length)) } +const COMMAND_PREFIXES = new Set([ + 'sudo', 'doas', + 'npx', 'bunx', + 'time', + 'nice', 'nohup', 'stdbuf', + 'rtk', +]) + export function extractBashCommands(rawCommand: string): string[] { if (!rawCommand || !rawCommand.trim()) return [] @@ -34,7 +42,17 @@ export function extractBashCommands(rawCommand: string): string[] { const tokens = segment.split(/\s+/) let i = 0 - while (i < tokens.length && /^\w+=/.test(tokens[i]!)) i++ + while (i < tokens.length) { + if (/^\w+=/.test(tokens[i]!)) { i++; continue } + const next = tokens[i + 1] + if ( + next !== undefined && + COMMAND_PREFIXES.has(basename(tokens[i]!)) && + !next.startsWith('-') && + !/["']/.test(next) + ) { i++; continue } + break + } const base = i < tokens.length ? basename(tokens[i]!) : '' if (base && base !== 'cd' && base !== 'true' && base !== 'false') { diff --git a/tests/bash-commands.test.ts b/tests/bash-commands.test.ts index 2c830e9d..3daf439a 100644 --- a/tests/bash-commands.test.ts +++ b/tests/bash-commands.test.ts @@ -73,6 +73,43 @@ describe('extractBashCommands', () => { it('handles env vars combined with chained commands', () => { expect(extractBashCommands('NODE_ENV=test npm test && git push')).toEqual(['npm', 'git']) }) + + it('skips command wrapper prefixes', () => { + expect(extractBashCommands('rtk git status')).toEqual(['git']) + expect(extractBashCommands('sudo npm install')).toEqual(['npm']) + expect(extractBashCommands('npx vitest --run')).toEqual(['vitest']) + }) + + it('skips prefix combined with env var assignment', () => { + expect(extractBashCommands('DEBUG=1 rtk git status')).toEqual(['git']) + }) + + it('skips nested wrapper prefixes', () => { + expect(extractBashCommands('sudo npx vitest --run')).toEqual(['vitest']) + }) + + it('skips prefix across chained commands', () => { + expect(extractBashCommands('rtk git add . && rtk git commit -m "msg"')).toEqual(['git', 'git']) + }) + + it('keeps a standalone prefix with no following command', () => { + expect(extractBashCommands('rtk')).toEqual(['rtk']) + expect(extractBashCommands('sudo')).toEqual(['sudo']) + }) + + it('keeps prefix when the next token is a flag', () => { + expect(extractBashCommands('nice -n 10 git push')).toEqual(['nice']) + }) + + it('skips env assignment that follows a wrapper prefix', () => { + expect(extractBashCommands('sudo NODE_ENV=production node server.js')).toEqual(['node']) + expect(extractBashCommands('time FOO=1 make build')).toEqual(['make']) + }) + + it('keeps prefix when the next token is quoted', () => { + expect(extractBashCommands('npx "@angular/cli" new app')).toEqual(['npx']) + expect(extractBashCommands("npx 'ts-node' script.ts")).toEqual(['npx']) + }) }) describe('BASH_TOOLS', () => {