/** * Shell command execution utilities with safety checks */ export interface CommandResult { success: boolean; stdout: string; stderr: string; exitCode: number; duration: number; command: string; args: string[]; /** Set when the command was stopped through `CommandOptions.signal`. */ cancelled?: boolean; /** Set when the command was killed at `CommandOptions.timeout`. Its partial * stdout and stderr are kept; the timeout note follows the stderr. */ timedOut?: boolean; } export interface CommandOptions { cwd?: string; timeout?: number; env?: Record; projectRoot?: string; /** * Stops the command when it fires: the child is killed and the result * comes back at once with `cancelled: true`. Honoured by * executeCommandAsync only — the sync runner cannot be interrupted. */ signal?: AbortSignal; } /** * Validate if a command is safe to execute (synchronous checks). * See validateCommandAsync for the DNS-resolving SSRF checks. */ export declare function validateCommand(command: string, args: string[], options?: CommandOptions): { valid: boolean; reason?: string; }; /** * The environment a validated command runs in. * * `git` is on ALLOWED_COMMANDS, so a skill's shell line, a `!` command or the * agent's own execute_command reaches git with whatever the repository put in * its `.git/config` — and several of those settings make git RUN a program: * a `filter..clean` fires during the index refresh `git status` does, * before anything looks like it executed code. Route git through the same * hardening Codeep's own git calls use. * * Hooks are deliberately left alone here. The command was approved as * written, so `git commit` through this path runs the repository's * pre-commit hook exactly as it would in the user's terminal. * * A caller's own `env` goes in as the BASE rather than on top of the result: * spread afterwards, their GIT_CONFIG_COUNT would replace ours and silently * drop every override above their count. * * The bare name is the whole test because it has to be: validateCommand() * only lets a command through when ALLOWED_COMMANDS holds it, and that set * holds `git`, not `/usr/bin/git`. A path-spelled git never reaches here. * * The directory scanned comes from the ARGV, not from the spawn's cwd: `git * -C vendor/lib status` reads the vendored checkout's config, so that is the * config that has to be neutralised. The argv forms that redirect git * somewhere this cannot follow (`--git-dir`, `--work-tree`, `--exec-path`, * `--config-env`) never get here — validateCommand() refuses them. * * Throws `GitHardeningError` when the repository's config cannot be scanned — * both runners below turn that into a failed CommandResult, because a refusal * is this command's own failure and the user reads it as such. * * EXPORTED, and this signature is the contract, because the ACP terminal * path spawns its own children and has to harden the SAME repository this * does. Call it with the parsed command, its argv, the cwd the spawn will * get and the caller's own env in `options.env`, and hand the result to the * spawn as `env` — do not spread anything over it, or a later * GIT_CONFIG_COUNT replaces ours and silently drops every override above it. * The argv is not optional there: `git -C vendor/lib status` scans * `vendor/lib`, and a caller that passes only the cwd hardens the wrong * repository. A shell LINE rather than an argv belongs to shellCommandEnv() * below instead. Both throw, and a refusal that escapes a promise executor * never settles it. */ export declare function commandEnv(command: string, args: string[], cwd: string, options?: CommandOptions): NodeJS.ProcessEnv; /** * The environment for a whole SHELL COMMAND LINE that may reach git. * * commandEnv() above can check a parsed binary name; a line handed to a shell * can reach git from anywhere inside it — `cd sub && git status`, `make && git * commit`, `foo | git apply` — so it needs its own entry point. This is that * entry point for the callers that spawn with `shell: true`: the skill runner * in src/acp/commands.ts and the one in src/renderer/agentExecution.ts, both * of which used to reach git raw. A hostile `gpg.program` that createCommit * neutralises still executed through those two spawns (proven, git 2.54). * * This is the ONE helper for that job — an earlier cut of this hotfix also * had a `hardenedShellEnv()` in utils/toolExecution.ts, which hardened every * skill step unconditionally and therefore refused an `echo` in a repository * whose config cannot be scanned. Keep it one: two helpers with two different * answers to "does a refusal stop this line?" is how one of them ends up * wrong and unused. * * The contract, since those two call sites are not this file's to edit: * * - Pass the command line, the cwd the shell will get and any env of your * own, and hand the RESULT to the spawn as `env`. Do not spread anything * over it — a later `GIT_CONFIG_COUNT` replaces ours and silently drops * every override above it. * - It THROWS `GitHardeningError` when the repository's config cannot be * scanned, or names a program no override can switch off. Catch it and fail * the command with `error.message`, which is written for the user. Letting * it escape a `spawnSync` call site turns a refusal into a crash; letting * it escape inside a promise executor leaves the caller hanging. * - Hooks are left alone, as they are for executeCommand(): the line was * approved as written, so `git commit` in it runs the repository's * pre-commit hook exactly as it would in the user's terminal. * - A line that cannot reach git comes back unhardened, so a repository with * an unreadable config does not also break `echo`. That is also why a * refusal never reaches a non-git line: an `echo` must not stop working * because some repository in the project sets `remote.origin.uploadpack`. * * WHAT THIS CAN AND CANNOT PROMISE, because a shell line is not an argv: * * - Scanned: the repository at `cwd`, AND every submodule of it — the ones * its index records as gitlinks and the ones its config records by name, * wherever each keeps its git directory (see listSubmoduleConfig in * utils/git.ts). Every key in REPO_EXECUTING_RULES * that any of them sets is neutralised, and because the overrides ride in * the ENVIRONMENT rather than in an argv, they apply wherever in the line * git ends up — so `cd vendor/lib && git add` is covered in full when * `vendor/lib` is a submodule, which is the shape a skill step usually has. * - Not scanned: a repository that is not `cwd` and not one of its * submodules — an independent checkout under `vendor/`, a sibling clone, * anywhere a `make` target cds to. There is no way to know where a shell * line ends up without running it, so this does not pretend to. What still * covers those is the always-on GIT_EXECUTING_CONFIG layer, which is why * `core.fsmonitor` is blanket there rather than scope-aware. The gap is the * keys GIT_CONFIG_* cannot wildcard — `filter.*` above all — in an * unrelated repository below the one scanned. Proven with git 2.54: `cd * vendor/lib && git status`, with `vendor/lib` a plain nested clone rather * than a submodule, did not run the nested `core.fsmonitor` and did run the * nested `filter..clean`. * - executeCommand()'s argv path has no such gap: it reads `-C` out of the * argv and scans where git will actually run, and refuses `--git-dir` / * `--work-tree` / `--exec-path` / `--config-env` outright. */ export declare function shellCommandEnv(commandLine: string, cwd: string, env?: Record): NodeJS.ProcessEnv; /** * Execute a shell command with safety checks */ export declare function executeCommand(command: string, args?: string[], options?: CommandOptions): CommandResult; /** * Async validation: everything in validateCommand plus the DNS-resolving * SSRF check for URL-carrying commands (curl/wget/http/https). Split from * the sync part because DNS lookups can't block the event loop. */ export declare function validateCommandAsync(command: string, args: string[], options?: CommandOptions): Promise<{ valid: boolean; reason?: string; }>; /** * Execute a shell command asynchronously (non-blocking) */ export declare function executeCommandAsync(command: string, args?: string[], options?: CommandOptions): Promise; /** * Execute a command and return only stdout if successful */ export declare function execSimple(command: string, args?: string[], options?: CommandOptions): string | null; /** * Execute a command asynchronously and return only stdout if successful */ export declare function execSimpleAsync(command: string, args?: string[], options?: CommandOptions): Promise; /** * Check if a command exists in PATH */ export declare function commandExists(command: string): boolean; /** * Get list of allowed commands */ export declare function getAllowedCommands(): string[]; /** * Format command result for display */ export declare function formatCommandResult(result: CommandResult): string;