/** * Command Guard * * Validates terminal commands before execution. The primary gate is a * pragmatic allowlist of base commands; anything else is refused, regardless * of whether it appears in the older eval / network / exfiltration pattern * lists. Pattern lists remain as defence-in-depth for cases where an * allowlisted command is composed dangerously (e.g. `python3 -m http.server`). */ import { type SecurityEvent } from './security-event.types.js'; export interface CommandValidationResult { allowed: boolean; reason?: string; } export declare class CommandGuard { private events; clearEvents(): void; getEvents(): SecurityEvent[]; validate(command: string): CommandValidationResult; /** * Decompose a command into every leaf segment that is reachable for * execution: chain operators, subshells `$(...)`, process substitutions * `<(...)` / `>(...)`, and backtick command substitutions are all * recursively peeled apart so each leaf base command can be checked * against the allowlist. */ private block; private checkEnvAccess; private checkExfiltrationPatterns; /** * Blocks deleting, relocating, truncating, or in-place-editing the security * audit log or MCP integrity baseline file, independent of per-agent * forbidden_paths — the forensic trail must not be tamperable even by an * agent otherwise allowed to run `rm`/`mv`/`sed`/etc within its working * directory. Operates per-segment (chain operators split commands apart * before this runs elsewhere too) on decoded tokens rather than whole-string * substring regexes, so neither argument order (`sed file -i` is as valid as * `sed -i file`) nor shell quoting/escaping can hide the flag or filename * from detection. Every filename-shaped argument is also checked against * its resolved real path, so a symlink with an unrelated name pointing at * the protected file is caught, not just a literal name match. */ private checkProtectedInfrastructurePatterns; private segmentRedirectsToProtectedFile; private segmentTargetsProtectedInfrastructure; /** * Public entry point for callers outside the command-execution path (e.g. * a CLI command validating its own `--out`-style flag) that need to reject * a target path matching a protected security-infrastructure file, without * duplicating the basename/realpath/inode-sharing logic those checks share * with every allowlisted-command scoping check in this file. */ isProtectedInfrastructureTarget(token: string): boolean; /** * True if the argument names a protected file directly, or resolves (via * a symlink, at any path depth) to one — so an alias with an unrelated * name can't hide the real target. Also true for an unresolved command * substitution placeholder: its real value can't be verified as safe, so * it's treated as a potential reference rather than silently passed. */ private tokenReferencesProtectedFile; /** * Extract the embedded sub-command(s) a "launcher" base command hands off * to another program, so they can be recursively re-validated. Returns an * empty array when no sub-command is found (e.g. `find -name '*.ts'` with * no `-exec`). */ private extractDockerHostPath; private extractEmbeddedSubCommands; /** * `env` with no sub-command at all dumps the entire process environment — * including any secret pulled in via `$ANTHROPIC_*`/`$*_API_KEY`/etc — with * no `$VAR` sigil anywhere in the command string, so `ENV_ACCESS_PATTERNS`' * textual `$VAR` matching never fires and `extractEnvSubCommand` finds * nothing to recurse into either. Bare `env` (or `env` piped/chained with * no sub-command of its own) must be blocked outright; `env VAR=x cmd...` * stays allowed since it hands off to a specific, recursively-revalidated * sub-command. */ /** * Decodes `rawArgs` via `decodeShellWord()` before any flag/assignment * matching — the original version compared raw, undecoded tokens, so a * quote-split flag (`-"C"`) evaded both this function's own parsing (a * disguised sub-command name could smuggle a blocked command past * re-validation) and `hasEnvChdirFlag`'s exact-string check (see * `validateEnvArgs`). Live-verified: creating a subdirectory literally * named after an allowlisted command and running `env -"C" * rm -rf /tmp/target` actually deleted the target directory. */ private extractEnvSubCommand; private validateEnvArgs; /** * `find`/`fd` support multiple `-exec ... \;` (or `-ok`/`-okdir`) clauses * per invocation — extracting only the first, as a single `findIndex` * would, lets a second clause smuggle an unvalidated sub-command past a * benign-looking first one (e.g. `find . -exec true \; -exec curl ... \;`). * This scans the whole argument list and returns every clause found. */ /** * Decodes `args` via `decodeShellWord()` before matching against * `FIND_EXEC_FLAGS` — the flag token itself can be quote-split * (`-'exec'`) exactly like any other argument in this file, and an * undecoded comparison never recognises it as `-exec` at all, so the * embedded sub-command is never extracted for re-validation and the whole * clause sails through unchecked. */ private extractFindExecSubCommand; private extractMountSource; private extractNestedSegments; /** * Decodes `args` via `decodeShellWord()` before matching against * `XARGS_VALUE_FLAGS` — an undecoded quote-split value flag (`-'L'`) * fails the exact-string match and falls through to the boolean-flag * branch, misaligning the index and treating the flag's own value as the * start of the sub-command. This fails closed (over-blocks a legitimate * invocation) rather than opening a bypass, but is fixed for consistency * with every other tokenizer in this file. */ private extractXargsSubCommand; private handleQuote; /** * Resolve symlinks in `candidatePath` as far as the filesystem allows. * `fs.realpathSync` requires every path component to exist, which throws * for a not-yet-created target (e.g. a file `rm` will delete, or a mount * source that doesn't exist yet) even when an intermediate symlinked * directory component does. Walking up to the deepest existing ancestor, * resolving that, and reattaching the missing tail handles both cases — * without this, a symlinked directory under cwd pointing outside it would * lexically "look" contained while actually escaping on disk. */ private isOutsideWorkingDirectory; private logEvent; private matchOperator; private splitAllSegments; private splitOnChainOperators; private stripNestedConstructs; private validateDockerArgs; /** * The build context is a single positional argument, but — unlike a shell * command line — docker's actual CLI parser (Cobra/pflag) does NOT require * it to be the final token: flags may legitimately follow it * (`docker build /some/context -f Dockerfile` is real, working docker * syntax, live-verified). Checking only `rest[rest.length - 1]` let an * out-of-cwd context slip through whenever any flag came after it. Rather * than building a flag-arity table to identify the one true context * token, every non-flag token is checked — matching `validateCwdScopedArgs`'s * existing "no flag-value bookkeeping needed" pattern for cp/mv/gunzip/gzip. * A flag's own value never resolves as an out-of-cwd absolute path in * practice (tags, targets, platforms, etc. aren't shaped like paths), so * this doesn't introduce new false positives. */ private validateDockerBuildContextArg; /** * `git` had zero scoping at all: `git -C clean -fdx` deletes * arbitrary files outside cwd (the same escape primitive `rm`/`cp`/docker * mounts are scoped against), the `ext::` remote-helper transport forks an * arbitrary program, and `core.hooksPath`/`credential.helper` (immediate * `-c`/`--config`, or persistent `git config`) set an arbitrary-command * hook. Live-verified: all four bypasses actually executed the smuggled * command/deleted the file in this sandbox before this fix. * * Tokens are decoded via `decodeShellWord()` before any check — the first * version of this method compared raw, undecoded tokens, which a * quote-split flag (`cor"e".hooksPath`, `-"C"`) defeated entirely. This is * the exact bypass class this file's own `decodeShellWord()` docstring * says took 3 rounds to close for other commands, reintroduced fresh here * — live-verified real hook execution and real file deletion through it * before this fix. * * `--git-dir`/`--work-tree` are scoped alongside `-C`: `--git-dir=` * ALONE (no `--work-tree`) uses the *current* cwd as the implicit * work-tree while reading from the external repo's object database, so * `git --git-dir=/.git checkout -- file.txt` overwrites a file * IN cwd with content from a completely external repo — the destination * argument looks perfectly ordinary, so no path-based heuristic on the * checkout target itself would catch this; only gating `--git-dir`'s own * value does. */ private validateGitArgs; /** `-C`/`--git-dir`/`--work-tree` all redirect which repository/working tree git operates against. */ private extractGitRootFlagTarget; /** Checks every bind-mount (-v/--mount) and I/O (-i/-o, save/load/export) host path for cwd-escape and protected-file targeting. */ private checkDockerHostPaths; /** * `-o`/`--output` (`save`, `export`) and `-i`/`--input` (`load`) take a * real host file path exactly like `-v`/`--mount`'s bind-mount source, but * are subcommand-specific — `-i` means something entirely different * ("interactive", no value) for `run`/`create`/`exec`, so this must only * fire for the subcommands where `-i`/`-o` really are host-path flags. * Also matches the short flag bundled with other single-char boolean * flags docker permits combining into one token (`-qi`) — see * `DOCKER_IO_BUNDLABLE_BOOLEAN_FLAGS`. */ private extractDockerIoHostPath; /** * `build`'s `-f`/`--file` (an arbitrary host file read as the Dockerfile), * `--iidfile`/`--metadata-file` (arbitrary host file writes), and * `-o`/`--output`'s `dest=` sub-value (BuildKit's arbitrary * host-directory export write, e.g. `type=local,dest=/tmp/exfil`) are all * real host-path flags, but `build` was never dispatched to any * docker-specific path check at all until round 11 added `-f`/`--iidfile`. */ private extractDockerBuildPathFlag; /** `-o`/`--output type=local,dest=` (BuildKit export) — its value is a comma-separated spec, not a bare path, so `dest=` must be pulled out separately from the plain path flags above. */ private extractDockerBuildOutputPathFlag; /** * `docker cp SRC DEST` uses positional arguments, not -v/--mount flags — * completely invisible to the bind-mount scoping loop above, which only * ever inspects flag tokens. Exactly one side is a `CONTAINER:PATH` * reference (not a host filesystem path at all, so cwd-scoping doesn't * apply); the other is a real host path and must resolve inside cwd, * same as `cp`'s own scoping. */ private validateDockerCpArgs; /** `docker cp`'s CONTAINER:PATH syntax, or bare "-" for stdin/stdout — neither is a host filesystem path. */ private isDockerCpContainerReference; /** * `docker import file|URL|- [REPOSITORY[:TAG]]` — the source is a bare * positional argument, not a `-v`/`--mount`/`-i` flag, so it was invisible * to every existing docker host-path check. A URL or `-` (stdin) isn't a * host filesystem path; only the first positional (the source) is ever a * path — the second positional is a `REPOSITORY[:TAG]` string, not one. */ private validateDockerImportArgs; private validateRmArgs; /** * `cp` had no path scoping at all — it can read from or write to any path * the process can reach (`cp /dev/null /etc/hosts`), with no equivalent to * rm's cwd check. Scoped the same way: every non-flag argument (source and * destination alike) must resolve inside the working directory. */ private validateCpArgs; /** * `mv`/`gunzip`/`gzip` are bucketed into `PROTECTED_INFRASTRUCTURE_DESTRUCTIVE_COMMANDS` * alongside `cp`/`rm`, but the general cwd-escape scoping dispatch never * covered them — `mv ./secret.txt /tmp/exfiltrated.txt` and * `mv /etc/hostname ./stolen` were both live-verified allowed. Shares * `cp`'s exact scoping logic (every non-flag argument must resolve inside * cwd) rather than duplicating it three times. */ private validateCwdScopedArgs; /** * `npm --prefix`/`pnpm -C`/`pnpm --dir` change the effective directory a * package.json's scripts run from — the same "changes effective cwd" * primitive already scoped for `git -C`/`env -C`. Live-verified against * real npm/pnpm: both execute an arbitrary package.json's scripts from * any directory on disk. `-C` never collides with real npm usage (npm has * no such short flag) and `--prefix` never collides with real pnpm usage, * so one check safely covers both package managers. */ private extractNpmPnpmRootFlagTarget; private validateNpmPnpmArgs; /** * `cat`/`head`/`tail`/`grep`/`rg`/`diff`/`stat` are read-only inspection * commands with no equivalent to rm/cp's path scoping at all — they could * read (and return to the LLM) any protected security-infrastructure file * (`vault-signing.key`, `security-audit.jsonl`, etc.) or any credential-shaped * file (`.env`, `id_rsa`, `.aws/credentials`) the exact same way `read_file` * and the LSP tools already refuse to. Not scoped to cwd in general — * these commands have many legitimate outside-cwd uses (comparing against * a reference file, inspecting system files) that rm/cp/docker don't; only * the specific files no legitimate agent task ever needs are blocked. */ private validateReadOnlyInspectionArgs; /** * jq/yq's own `env`/`$ENV` filter-language constructs dump the full * process environment with no `$VAR` shell-sigil ever appearing in the * command string — checked against the whole joined argument string, * mirroring `DOCKER_DANGEROUS_FLAG_PATTERNS`. */ private validateJqYqArgs; /** * `gh` had zero scoping infrastructure at all. `gh alias set * '!'` is documented gh CLI syntax for a shell-command alias * (live-verified real RCE via `gh alias set pwn '!...'` then `gh pwn`); * `gh extension install`/`upgrade` installs and runs arbitrary * third-party code with no sandboxing. */ private validateGhArgs; /** `sort`/`tree`'s `-o`/`--output ` write to an arbitrary host path — the same class of gap `cp` originally had. */ private validateOutputFlagArgs; /** GNU `uniq`'s optional second positional (`uniq [OPTION]... [INPUT [OUTPUT]]`) is a bare output-path positional, not a flag. */ private validateUniqArgs; /** * `eslint --config`/`-c`, `prettier --plugin`, and `vitest --config` all * `require()`/execute the pointed-to file as real code on load — an * outside-cwd path is a direct RCE primitive, live-verified for all three. */ private validateConfigPathArgs; /** * pytest auto-imports `conftest.py` from any directory it's pointed at — * zero flags needed, cheaper than the already-accepted "pytest runs your * own project's code" boundary. Every non-flag positional is scoped like * `cp`'s blanket check (a NodeID's `::test_name` suffix is stripped * first), plus `--rootdir`, which takes the same kind of directory path. */ private validatePytestArgs; /** * Cheap, name-only checks on the base command: homoglyph obfuscation, * network commands, remote-access commands. Split out of `validateSegment` * to keep its own cyclomatic complexity down. */ private checkBaseCommandSafety; private validateSegment; /** * Rules that only apply to specific allowlisted base commands: docker flag * scoping, rm path scoping, and embedded sub-command validation for * exec-argument launchers. Split out of `validateSegment` to keep its own * cyclomatic complexity down. */ private validateCommandSpecificRules; /** Dispatches to the per-command path/flag scoping check, if this base command has one. */ private checkScopedPathCommand; } export declare function getCommandGuard(): CommandGuard; export declare function resetCommandGuard(): void; //# sourceMappingURL=command-guard.d.ts.map