/** * Decode a run of invisible characters to its likely payload. Recognizes the * two common smuggling encodings — Unicode tag characters (U+E0001–U+E007F map * directly to ASCII) and zero-width binary (ZWSP=0, ZWNJ=1, ZWJ=separator) — * and otherwise reports the raw code points. The tag-character payload is * rendered as a neutral, quoted/escaped `untrusted data, not instructions: "…"` * string (see {@link neutralizeTagBytes}) so the report can never re-inject the * hidden instruction, and only U+E0020–U+E007E map to raw printable ASCII. * @param {string} run * @returns {{ method: string, decoded: string }} */ export function decodeRun(run: string): { method: string; decoded: string; }; /** * Scan a file's text for hidden-Unicode injection. Reports each long invisible * run (with its decoded payload) plus a single scattered-chars finding when the * non-run invisible count crosses the threshold-evasion floor. * @param {string} content * @returns {Array<{ line: number | null, charCount: number, method: string, decoded: string }>} * `line` is the 1-based line of a long-run finding, or `null` for the * whole-file scattered-chars finding (not tied to a single line). */ export function scanText(content: string): Array<{ line: number | null; charCount: number; method: string; decoded: string; }>; /** * Expand `globs` (relative to `cwd`) to absolute file paths, skipping * `node_modules`. The glob set is the caller's instruction-file convention. * * Containment is enforced per match (see {@link keepContained}): a match whose * glob pattern itself escapes `cwd` — via `..` or an absolute-path glob * outside the tree — THROWS, since reaching outside the tree is a caller * misconfiguration. A match that lexically stays inside `cwd` but resolves * (via an in-tree symlink) to a target outside the tree, or that simply * cannot be resolved (a dangling symlink or unreadable entry inside the * tree), is SKIPPED, so one bad symlink never aborts scanning the rest of the * project. * @param {string[]} globs * @param {{ cwd?: string }} [options] * @returns {string[]} */ export function findInstructionFiles(globs: string[], { cwd }?: { cwd?: string; }): string[]; /** * Scan every instruction file matched by `globs` and return only those with * findings, each path reported relative to `cwd`. Unreadable/missing files are * skipped. Pure scan — no mutation; pair with {@link cleanFile} to strip. * @param {string[]} globs * @param {{ cwd?: string }} [options] * @returns {Array<{ file: string, findings: ReturnType }>} */ export function scanInstructionFiles(globs: string[], { cwd }?: { cwd?: string; }): Array<{ file: string; findings: ReturnType; }>; /** * Atomically replace `absPath`'s contents with `data`, preserving `mode`. * * Writes to a sibling temp in the same directory, then `rename`s it over the * original (same dir => same filesystem => the rename is atomic, not a * cross-device copy). The temp name is UNPREDICTABLE (`tmpName()` defaults to * crypto-random) and the temp is created exclusively (O_CREAT|O_EXCL): if the * path already exists — including an attacker-planted symlink at a guessable * temp name — the open fails (EEXIST) and does NOT follow the link to clobber * its target. On the rare collision we fail loud rather than retry into a * different attacker-controlled path. * * Crash-safety (matching the doc claim): the temp fd is `fsync`ed before the * rename and the directory fd is `fsync`ed after it, so a power loss can't leave * the renamed name pointing at unflushed/empty data or lose the rename itself. * The EXACT `mode` is applied with `fchmod` (openSync's create mode is * umask-masked, so it alone would drop bits), and a failed write/sync `unlink`s * the temp before rethrowing so no partial temp leaks. `tmpName` and `remove` * are injectable fault-injection seams for tests (force a known temp path; drive * a cleanup-unlink failure); production callers never pass them. * @param {string} absPath * @param {string} data * @param {number} mode * @param {() => string} [tmpName] * @param {(path: string) => void} [remove] */ export function atomicReplaceFile(absPath: string, data: string, mode: number, tmpName?: () => string, remove?: (path: string) => void): void; /** * Strip payload-capable invisible characters from `absPath` in place. Returns * `true` when the file's bytes actually changed (a payload {@link scanText} * flags was removed), `false` when {@link scanText} reports nothing, and `null` * when scan flagged a payload but {@link stripInvisible} removes nothing — a * fail-closed signal that the flagged run was PRESERVED (e.g. a well-formed * emoji-tag sequence the stripper keeps), so the caller must not treat it as * cleaned. `true` means and only means "bytes changed". * * Contract (scan/clean coherence): clean strips exactly what scan flags. A * write happens ONLY when `scanText` reports a finding, so the "scan, then * clean what scan flagged" workflow never silently rewrites a file scan called * clean. A handful of sub-threshold invisible chars (which scan ignores) are * left untouched — by design, the scanner's definition of a payload is the * single source of truth for what gets removed. * * Refuses to follow symlinks: instruction files must be regular files. The read * fd is opened with `O_NOFOLLOW`, so a symlinked path (which could redirect the * read/write to a target outside the tree) makes the OPEN itself fail — closing * the lstat→open TOCTOU window a separate stat would leave, in which the path * could be swapped to a symlink between the check and the read. * * Non-UTF-8 safety (O9): the file is read as raw BYTES and required to round-trip * losslessly through UTF-8 before any rewrite. `readFileSync(…, "utf-8")` * silently maps invalid bytes to U+FFFD, which a naive strip-and-rewrite would * then persist file-wide — so a non-UTF-8 file fails loud and is left untouched. * * Lost-update / TOCTOU guard: the on-path file is re-checked against the fstat * snapshot taken right after open (inode, size, mtime, and not-a-symlink) before * the rename; a concurrent write or symlink swap between our read and our write * fails loud rather than silently clobbering the other writer. * * The write is atomic (see {@link atomicReplaceFile}): stripped content goes to * a temp file in the same directory which is then `rename`d over the original * (preserving the original file mode), fsync'd for crash-safety. * * Throws if the file cannot be read or written (the caller decides whether an * unwritable contaminated file is fatal or falls back to alerting). * @param {string} absPath * @param {(path: string) => import("node:fs").Stats} [lstat] injectable * pre-rename recheck stat (fault-injection seam, mirrors * {@link atomicReplaceFile}'s `tmpName`): lets a test drive the concurrent * write/symlink-swap that the TOCTOU guard exists to catch, which is otherwise * unreachable from this fully-synchronous path. Defaults to `lstatSync`. * @returns {boolean} */ export function cleanFile(absPath: string, lstat?: (path: string) => import("node:fs").Stats): boolean;