* Run a command in the persistent shell. Returns { stdout, exitCode, timedOut }. * Output is sanitized (ANSI stripped, secrets redacted) before returning.
(command)
| 153 | * Output is sanitized (ANSI stripped, secrets redacted) before returning. |
| 154 | */ |
| 155 | async run(command) { |
| 156 | if (this._dead) { |
| 157 | // Auto-restart on dead shell |
| 158 | const ok = await this.start(); |
| 159 | if (!ok) return { stdout: '', exitCode: -1, timedOut: false, error: 'shell unavailable' }; |
| 160 | } |
| 161 | if (!this.proc) { |
| 162 | const ok = await this.start(); |
| 163 | if (!ok) return { stdout: '', exitCode: -1, timedOut: false, error: 'shell failed to start' }; |
| 164 | } |
| 165 | |
| 166 | // Optional containment: parse cwd-changing commands and reject ones that |
| 167 | // would leave the project root. We strip leading `;` `&` `&&` so chained |
| 168 | // commands are inspected too. Catches: |
| 169 | // cd ../../etc |
| 170 | // cd "../../etc" |
| 171 | // cd '..' |
| 172 | // pushd .. |
| 173 | // ; cd .. |
| 174 | // && cd .. |
| 175 | // bash -c "cd .." (refused outright — sub-shells bypass our wrapper) |
| 176 | // sh -c '...' (same) |
| 177 | if (this.containCwd) { |
| 178 | // Reject sub-shell escapes — we cannot track cwd through them |
| 179 | if (/\b(?:bash|sh|zsh|ksh|fish|pwsh|powershell|cmd)\s+-c\b/.test(command)) { |
| 180 | return { stdout: `(refused: -c sub-shells bypass cwd containment)\n`, exitCode: 1, timedOut: false }; |
| 181 | } |
| 182 | // Iterate every cd / pushd / chdir (chained or not) |
| 183 | const cdRe = /(?:^|[;&|])\s*(?:cd|pushd|chdir)\s+([^\s;&|]+)/g; |
| 184 | let cdMatch; |
| 185 | let simulatedCwd = this.cwd; |
| 186 | while ((cdMatch = cdRe.exec(command))) { |
| 187 | const target = cdMatch[1].replace(/^['"]|['"]$/g, ''); |
| 188 | const resolved = path.isAbsolute(target) ? target : path.resolve(simulatedCwd, target); |
| 189 | const rel = path.relative(this.rootDir, resolved); |
| 190 | if (rel.startsWith('..') || path.isAbsolute(rel)) { |
| 191 | return { stdout: `(cd refused: target outside project root)\n`, exitCode: 1, timedOut: false }; |
| 192 | } |
| 193 | simulatedCwd = resolved; |
| 194 | } |
| 195 | } |
| 196 | |
| 197 | const sentinel = SENTINEL_PREFIX + crypto.randomBytes(8).toString('hex'); |
| 198 | const isWin = process.platform === 'win32'; |
| 199 | |
| 200 | // Wrap the command so we can detect end-of-output and capture exit code. |
| 201 | // POSIX: `; printf "\n__SENTINEL__%d__\n" $?` |
| 202 | // Windows cmd: `& echo __SENTINEL__%errorlevel%__` |
| 203 | const wrapped = isWin |
| 204 | ? `${command}\r\n@echo ${sentinel}_%errorlevel%_\r\n` |
| 205 | : `${command}\nprintf '\\n${sentinel}_%d_\\n' $?\n`; |
| 206 | |
| 207 | return new Promise((resolve) => { |
| 208 | let timedOut = false; |
| 209 | const timer = setTimeout(() => { |
| 210 | timedOut = true; |
| 211 | // On timeout we mark the shell dead and reset it — the next command |
| 212 | // will spawn a fresh shell. Half-measures (writing \n, sending SIGINT) |
no test coverage detected