/** * Command-string normalization shared by the shell hardening layer. The hardline * blocklist (`core/shell/hardline`) and the EXT-9 Tier-2 * allow-list classifier ({@link @gaunt-sloth/core!core/shell/arity | ./arity.js}) both match against the *normalized* form so * trivial obfuscation (ANSI escapes, fullwidth glyphs, backslash splits, padded * whitespace) cannot smuggle a command past the guard. Canonical home is core so every * consumer — the allow-list, the hardline floor, the approvals gate — imports one implementation. * * Patterned after hermes-agent `tools/approval.py:_normalize_command_for_detection`. * * @module */ /** * SECURITY / EXT-55 — the single source of truth for **where a new command begins**. * * This is a regex character-class BODY (embed it as `` `[${COMMAND_SEPARATOR_CLASS}]` ``) listing * every character at which the shell stops one command and starts the next: `;`, `&` (hence * `&&`), `|` (hence `||`), and a LINE BREAK. Both consumers of the normalized form build their * patterns from it — the allow-list classifier's fail-closed check * ({@link @gaunt-sloth/core!core/shell/arity.classifyCommand | classifyCommand}) and the hardline blocklist's pattern * terminators (`core/shell/hardline`) — so the two layers can never again * disagree about what a separator is. * * The layers disagreed before EXT-55: `;`/`&&`/`|` made a command ambiguous (fail-closed) but a * newline did not, because {@link normalizeCommand} folded it to a SPACE. `ls -la\nrm -rf /` was * therefore classified as the single command `ls`, and an ordinary `ls` grant auto-approved it. * * `\r` is listed defensively: {@link normalizeCommand} canonicalizes CR/CRLF to LF, so a * normalized string never contains one — but a pattern matched against a RAW command still fails * closed. */ export declare const COMMAND_SEPARATOR_CLASS = ";&|\\n\\r"; /** {@link COMMAND_SEPARATOR_CLASS} as a ready-made single-character matcher. */ export declare const COMMAND_SEPARATOR_RE: RegExp; /** * Matches any line break — the separator {@link normalizeCommand} used to destroy (EXT-55). * Exported so a caller can ask "is this more than one command?" of a RAW string without * depending on someone else's normalizer having preserved the boundary. */ export declare const LINE_BREAK_RE: RegExp; /** * Normalize a command string before dangerous-pattern matching. * * Steps (each closes an obfuscation bypass): * - strip ANSI escape sequences (CSI / OSC / lone-escape), * - drop null bytes, * - Unicode NFKC fold (fullwidth `rm` → `rm`, etc.), * - canonicalize every line ending (CRLF / lone CR) to a bare `\n`, * - collapse shell backslash-escapes (`r\m` → `rm`, `\-rf` → `-rf`), * - drop empty-string literals that split tokens (`r''m` / `r""m` → `rm`), * - fold runs of HORIZONTAL whitespace (spaces/tabs) to single spaces, * - collapse each run of line breaks to a single `\n` — **which survives**, because a line break * is a command separator, not padding (EXT-55) — and trim the ends. * * EXT-55: the last two steps used to be one `\s+ → ' '` fold, which erased the command boundary * and let `ls -la\nrm -rf /` be read as the single command `ls`. A line break now reaches every * consumer intact, exactly like `;`. Leading/trailing breaks are still trimmed, so the very * common `"npm test\n"` tool argument remains ONE command and keeps matching the allow-list. * * A backslash before a line break (a shell line continuation) is deliberately NOT joined: folding * it away would re-open the same hole for `ls \rm -rf /`. Keeping the boundary makes such * a command categorically ambiguous, which costs a prompt, not a failure. * * This is intentionally lossy: the normalized form is ONLY used for detection, * never for execution (the original command is what runs). */ export declare function normalizeCommand(command: string): string;