basant307/AI_Governance_Project
048
1/**2 * @license3 * Copyright 2025 Qwen team4 * SPDX-License-Identifier: Apache-2.05 */6 7/**8 * Shell command semantic analysis for permission matching.9 *10 * Analyzes simple shell commands to extract "virtual tool operations" so that11 * Read / Edit / Write / WebFetch / ListFiles permission rules can match their12 * shell equivalents and prevent bypass via the shell tool.13 *14 * @example15 * extractShellOperations('cat /etc/passwd', '/home/user')16 * // → [{ virtualTool: 'read_file', filePath: '/etc/passwd' }]17 *18 * @example19 * extractShellOperations('curl https://example.com/api', '/home/user')20 * // → [{ virtualTool: 'web_fetch', domain: 'example.com' }]21 *22 * @example23 * extractShellOperations('echo hi > /etc/motd', '/home/user')24 * // → [{ virtualTool: 'write_file', filePath: '/etc/motd' }]25 *26 * Known limitations (cannot be statically analysed):27 * - Shell variable expansion: `cat $FILE`28 * - Command substitution: `cat $(find .)`29 * - Interpreter scripts: `python script.py`, `node x.js`30 * - Pipe targets: `find . | xargs cat`31 * - Complex dynamic expressions: `eval "cat $f"`32 */33 34import nodePath from 'node:path';35import os from 'node:os';36import { stripShellWrapper } from '../utils/shell-utils.js';37import { createDebugLogger } from '../utils/debugLogger.js';38import { splitCompoundCommand } from './rule-parser.js';39 40const shellSemanticsDebugLogger = createDebugLogger('SHELL_SEMANTICS');41 42// ─────────────────────────────────────────────────────────────────────────────43// Types44// ─────────────────────────────────────────────────────────────────────────────45 46/**47 * A virtual file or network operation extracted from a shell command.48 * Used to match Read / Edit / Write / WebFetch / ListFiles permission rules49 * against shell commands that perform equivalent operations.50 */51export interface ShellOperation {52 /**53 * The virtual tool this operation maps to.54 * Matches the canonical tool names used in the permission system.55 */56 virtualTool:57 | 'read_file'58 | 'list_directory'59 | 'edit'60 | 'write_file'61 | 'web_fetch'62 | 'grep_search';63 /** Absolute file or directory path (for file operations). */64 filePath?: string;65 /** Domain name without port (for web_fetch operations). */66 domain?: string;67 /**68 * True when this operation was extracted after a dynamic `cd` whose target69 * cannot be statically resolved. Consumers that enforce protected relative70 * paths should treat this as conservative signal, not as a concrete path.71 */72 cwdUnknown?: boolean;73 /**74 * True when `cwdUnknown` may affect the extracted file path. Absolute paths75 * do not depend on cwd; relative redirect/path arguments do.76 */77 pathMayDependOnCwd?: boolean;78}79 80// ─────────────────────────────────────────────────────────────────────────────81// Tokenizer82// ─────────────────────────────────────────────────────────────────────────────83 84/**85 * Tokenize a shell command string, respecting single/double quotes and86 * backslash escapes, splitting on unquoted whitespace.87 *88 * The input should be a single simple command (already split from compound89 * commands via `splitCompoundCommand`).90 */91function tokenize(command: string): string[] {92 const tokens: string[] = [];93 let current = '';94 let inSingle = false;95 let inDouble = false;96 let escaped = false;97 98 for (let i = 0; i < command.length; i++) {99 const ch = command[i]!;100 101 if (escaped) {102 current += ch;103 escaped = false;104 continue;105 }106 if (ch === '\\' && !inSingle) {107 escaped = true;108 continue;109 }110 if (ch === "'" && !inDouble) {111 inSingle = !inSingle;112 continue;113 }114 if (ch === '"' && !inSingle) {115 inDouble = !inDouble;116 continue;117 }118 if (!inSingle && !inDouble && (ch === ' ' || ch === '\t')) {119 if (current) {120 pushToken(tokens, current);121 current = '';122 }123 continue;124 }125 current += ch;126 }127 if (current) pushToken(tokens, current);128 return tokens;129}130 131function pushToken(tokens: string[], token: string): void {132 if (token === '{' || token === '}') return;133 const normalized = trimShellSyntax(token);134 if (normalized) tokens.push(normalized);135}136 137function trimShellSyntax(token: string): string {138 let start = 0;139 let end = token.length;140 141 while (start < end && token[start] === '(') {142 start++;143 }144 while (end > start) {145 const ch = token[end - 1];146 if (ch !== ')' && ch !== '&') break;147 end--;148 }149 150 return token.slice(start, end);151}152 153// ─────────────────────────────────────────────────────────────────────────────154// Path helpers155// ─────────────────────────────────────────────────────────────────────────────156 157/**158 * Resolve a path argument to an absolute POSIX-style path.159 * Handles `~` home-directory expansion and relative paths.160 *161 * Always returns paths with forward-slash separators so that the resolved162 * paths are consistent across platforms and compatible with picomatch / the163 * permission rule matching system.164 */165function resolvePath(p: string, cwd: string): string {166 // Normalize inputs to forward slashes for consistent cross-platform handling167 const normP = p.replace(/\\/g, '/');168 const normCwd = cwd.replace(/\\/g, '/');169 170 if (normP === '~' || normP.startsWith('~/')) {171 const homeDir = os.homedir().replace(/\\/g, '/');172 const rest = normP.slice(1); // '' or '/some/path'173 // nodePath.posix.join handles the rest correctly:174 // join('C:/Users/foo', '/.ssh/id_rsa') → 'C:/Users/foo/.ssh/id_rsa'175 return rest ? nodePath.posix.join(homeDir, rest) : homeDir;176 }177 if (isShellAbsolutePath(normP)) {178 return normP;179 }180 return nodePath.posix.join(normCwd, normP);181}182 183function isShellAbsolutePath(p: string): boolean {184 return p.startsWith('/') || /^[A-Za-z]:\//.test(p.replace(/\\/g, '/'));185}186 187function isEnvAssignmentToken(token: string): boolean {188 return /^[A-Za-z_][A-Za-z0-9_]*=/.test(token);189}190 191/**192 * Return true if a token looks like a file/directory path argument, as193 * opposed to a flag, shell variable, number, or script expression.194 */195function looksLikePath(s: string): boolean {196 if (!s) return false;197 // Shell variable references198 if (s.startsWith('$')) return false;199 // Flags200 if (s.startsWith('-')) return false;201 // Pure integers — likely a count/size/mode argument (e.g. -n 10, chmod 755)202 if (/^\d+$/.test(s)) return false;203 // Script-like expressions (awk/sed programs, brace expansions)204 if (s.includes('{') || s.includes('}')) return false;205 // URLs are handled separately by the web-fetch handlers206 if (s.includes('://')) return false;207 return true;208}209 210// ─────────────────────────────────────────────────────────────────────────────211// Redirect extraction212// ─────────────────────────────────────────────────────────────────────────────213 214interface RedirectResult {215 readFiles: string[];216 writeFiles: string[];217}218 219/**220 * A bash /dev/tcp/<host>/<port> or /dev/udp/... redirect target opens a221 * network socket, not a file. Such targets must not be reported as file222 * reads/writes.223 */224function isNetworkPseudoDevice(target: string): boolean {225 return /^\/dev\/(tcp|udp)\//.test(target);226}227 228/**229 * Extract I/O redirections from a token array.230 *231 * Modifies `tokens` in-place to remove redirect operators and their targets.232 * Returns the absolute paths of redirect targets as read / write operations.233 *234 * Handles:235 * `> file` `>> file` `< file` (with or without space)236 * `2> file` `2>> file` `&> file` `&>> file`237 * Combined forms: `>file`, `>>file`, `1>file`, `2>/dev/null`238 */239function extractRedirects(tokens: string[], cwd: string): RedirectResult {240 const readFiles: string[] = [];241 const writeFiles: string[] = [];242 const toRemove = new Set<number>();243 244 for (let i = 0; i < tokens.length; i++) {245 const tok = tokens[i]!;246 247 // ── Separate-token redirect operators ─────────────────────────────────248 if (tok === '>' || tok === '1>') {249 const target = tokens[i + 1];250 if (target && looksLikePath(target)) {251 if (!isNetworkPseudoDevice(target)) {252 writeFiles.push(resolvePath(target, cwd));253 }254 toRemove.add(i);255 toRemove.add(i + 1);256 i++;257 }258 } else if (tok === '>>' || tok === '1>>') {259 const target = tokens[i + 1];260 if (target && looksLikePath(target)) {261 if (!isNetworkPseudoDevice(target)) {262 writeFiles.push(resolvePath(target, cwd));263 }264 toRemove.add(i);265 toRemove.add(i + 1);266 i++;267 }268 } else if (tok === '<<' || tok === '<<-') {269 toRemove.add(i);270 if (tokens[i + 1]) {271 toRemove.add(i + 1);272 i++;273 }274 } else if (tok === '<') {275 const target = tokens[i + 1];276 if (target && looksLikePath(target)) {277 if (!isNetworkPseudoDevice(target)) {278 readFiles.push(resolvePath(target, cwd));279 }280 toRemove.add(i);281 toRemove.add(i + 1);282 i++;283 }284 } else if (tok === '2>' || tok === '2>>' || tok === '&>' || tok === '&>>') {285 // stderr / combined redirect — consume target286 const target = tokens[i + 1];287 if (target) {288 if (289 target !== '/dev/null' &&290 looksLikePath(target) &&291 !isNetworkPseudoDevice(target)292 ) {293 writeFiles.push(resolvePath(target, cwd));294 }295 toRemove.add(i);296 toRemove.add(i + 1);297 i++;298 }299 }300 // ── Combined redirect tokens without space: `>file`, `>>file`, etc. ───301 else {302 const m = tok.match(/^(<<-?|1>>|1>|>>|>|2>>|2>|&>>|&>|<)(.+)$/);303 if (m) {304 const op = m[1]!;305 const target = m[2]!;306 if (op.startsWith('<<')) {307 toRemove.add(i);308 continue;309 }310 if (311 target !== '/dev/null' &&312 looksLikePath(target) &&313 !isNetworkPseudoDevice(target)314 ) {315 if (op === '<') {316 readFiles.push(resolvePath(target, cwd));317 } else {318 writeFiles.push(resolvePath(target, cwd));319 }320 }321 toRemove.add(i);322 }323 }324 }325 326 // Remove redirect tokens from the array in-place327 const filtered = tokens.filter((_, idx) => !toRemove.has(idx));328 tokens.length = 0;329 tokens.push(...filtered);330 331 return { readFiles, writeFiles };332}333 334// ─────────────────────────────────────────────────────────────────────────────335// Argument parsing336// ─────────────────────────────────────────────────────────────────────────────337 338/**339 * Extract positional (non-flag) arguments from a token list.340 *341 * Flags starting with `-` are skipped. Flags listed in `flagsWithValue`342 * also consume the immediately following token (their value).343 */344function getPositionalArgs(345 args: string[],346 flagsWithValue: ReadonlySet<string> = new Set(),347): string[] {348 const positional: string[] = [];349 let skipNext = false;350 351 for (const arg of args) {352 if (skipNext) {353 skipNext = false;354 continue;355 }356 if (!arg.startsWith('-')) {357 positional.push(arg);358 continue;359 }360 const equalsIndex = arg.indexOf('=');361 if (equalsIndex > 0 && flagsWithValue.has(arg.slice(0, equalsIndex))) {362 continue;363 }364 // Flag: check if it consumes the next token365 if (flagsWithValue.has(arg)) {366 skipNext = true;367 continue;368 }369 for (const flag of flagsWithValue) {370 if (isAttachedShortFlagValue(arg, flag)) {371 break;372 }373 if (374 flag.startsWith('-') &&375 !flag.startsWith('--') &&376 flag.length === 2 &&377 hasCombinedShortFlag(arg, flag.slice(1))378 ) {379 skipNext = true;380 break;381 }382 }383 // Flags combined with their value in the same token (`-n10`) are ignored384 // because looksLikePath will filter out anything starting with `-`.385 }386 387 return positional;388}389 390function getFlagValue(391 args: string[],392 shortName: string,393 longName: string,394): string | undefined {395 for (let i = 0; i < args.length; i++) {396 const arg = args[i]!;397 if (arg === shortName || arg === longName) {398 return args[i + 1];399 }400 if (arg.startsWith(`${longName}=`)) {401 return arg.slice(longName.length + 1);402 }403 if (isAttachedShortFlagValue(arg, shortName)) {404 return arg.slice(shortName.length).replace(/^=/, '');405 }406 if (hasCombinedShortFlag(arg, shortName.slice(1))) {407 return args[i + 1];408 }409 }410 return undefined;411}412 413function targetDirectoryPath(args: string[], cwd: string): string | undefined {414 const target = getFlagValue(args, '-t', '--target-directory');415 if (!target || !looksLikePath(target)) return undefined;416 return resolvePath(target, cwd);417}418 419function targetDirectoryWrites(420 targetDir: string,421 sources: string[],422): ShellOperation[] {423 return sources.map((source) => ({424 virtualTool: 'write_file',425 filePath: nodePath.posix.join(426 targetDir,427 nodePath.posix.basename(source.replace(/\\/g, '/')),428 ),429 }));430}431 432function writeOpForFlag(433 args: string[],434 cwd: string,435 shortName: string,436 longName: string,437): ShellOperation | undefined {438 const target = getFlagValue(args, shortName, longName);439 if (!target || !looksLikePath(target)) return undefined;440 return { virtualTool: 'write_file', filePath: resolvePath(target, cwd) };441}442 443function hasCombinedShortFlag(arg: string, flag: string): boolean {444 return (445 arg.startsWith('-') && !arg.startsWith('--') && arg.slice(1).includes(flag)446 );447}448 449function isAttachedShortFlagValue(arg: string, flag: string): boolean {450 return (451 flag.startsWith('-') &&452 !flag.startsWith('--') &&453 flag.length === 2 &&454 arg.startsWith(flag) &&455 arg.length > flag.length456 );457}458 459// ─────────────────────────────────────────────────────────────────────────────460// Command handler helpers461// ─────────────────────────────────────────────────────────────────────────────462 463type CommandHandler = (args: string[], cwd: string) => ShellOperation[];464 465/** Build read_file operations from positional path arguments. */466function readOps(467 args: string[],468 cwd: string,469 flagsWithValue?: ReadonlySet<string>,470): ShellOperation[] {471 return getPositionalArgs(args, flagsWithValue)472 .filter(looksLikePath)473 .map((p) => ({474 virtualTool: 'read_file' as const,475 filePath: resolvePath(p, cwd),476 }));477}478 479/** Build list_directory operations from positional path arguments.480 * Defaults to cwd when no path args are given. */481function listOps(482 args: string[],483 cwd: string,484 flagsWithValue?: ReadonlySet<string>,485): ShellOperation[] {486 const dirs = getPositionalArgs(args, flagsWithValue).filter(looksLikePath);487 if (dirs.length === 0)488 return [{ virtualTool: 'list_directory', filePath: cwd }];489 return dirs.map((p) => ({490 virtualTool: 'list_directory' as const,491 filePath: resolvePath(p, cwd),492 }));493}494 495/** Extract URL domain and return a web_fetch operation, or null on failure. */496function webOp(url: string): ShellOperation | null {497 try {498 const normalized = url.includes('://') ? url : `https://${url}`;499 const domain = new URL(normalized).hostname;500 return domain ? { virtualTool: 'web_fetch', domain } : null;501 } catch {502 return null;503 }504}505 506// ─────────────────────────────────────────────────────────────────────────────507// Command dispatch table508// ─────────────────────────────────────────────────────────────────────────────509 510const COMMANDS: Readonly<Record<string, CommandHandler>> = {511 // ── File-read commands ────────────────────────────────────────────────────512 513 cat: (a, d) => readOps(a, d),514 tac: (a, d) => readOps(a, d),515 nl: (a, d) => readOps(a, d),516 zcat: (a, d) => readOps(a, d),517 bzcat: (a, d) => readOps(a, d),518 xzcat: (a, d) => readOps(a, d),519 gzcat: (a, d) => readOps(a, d),520 lzcat: (a, d) => readOps(a, d),521 head: (a, d) => readOps(a, d, new Set(['-n', '-c', '--lines', '--bytes'])),522 tail: (a, d) =>523 readOps(524 a,525 d,526 new Set(['-n', '-c', '-s', '--lines', '--bytes', '--sleep-interval']),527 ),528 less: (a, d) =>529 readOps(530 a,531 d,532 new Set(['-b', '-h', '-j', '-p', '-x', '-y', '-z', '--shift', '--tabs']),533 ),534 more: (a, d) => readOps(a, d),535 most: (a, d) => readOps(a, d),536 wc: (a, d) => readOps(a, d),537 file: (a, d) =>538 readOps(539 a,540 d,541 new Set([542 '-m',543 '-e',544 '-F',545 '-P',546 '--magic-file',547 '--exclude',548 '--extension',549 '--separator',550 ]),551 ),552 stat: (a, d) =>553 readOps(554 a,555 d,556 new Set(['-c', '-f', '--format', '--printf', '--file-system']),557 ),558 readlink: (a, d) =>559 readOps(560 a,561 d,562 new Set([563 '-e',564 '-f',565 '-m',566 '-q',567 '-s',568 '-v',569 '-z',570 '--canonicalize',571 '--canonicalize-existing',572 '--canonicalize-missing',573 '--no-newline',574 '--quiet',575 '--silent',576 '--verbose',577 '--zero',578 ]),579 ),580 realpath: (a, d) =>581 readOps(582 a,583 d,584 new Set([585 '--relative-to',586 '--relative-base',587 '-e',588 '-m',589 '-s',590 '-z',591 '--canonicalize-existing',592 '--canonicalize-missing',593 '--logical',594 '--physical',595 '--no-symlinks',596 '--quiet',597 '--strip',598 '--zero',599 ]),600 ),601 diff: (a, d) =>602 readOps(603 a,604 d,605 new Set([606 '-u',607 '-U',608 '-c',609 '-C',610 '-I',611 '-x',612 '-X',613 '-W',614 '--label',615 '--to-file',616 '--from-file',617 '--width',618 '--horizon-lines',619 '--strip-trailing-cr',620 '--ignore-matching-lines',621 '--exclude',622 '--exclude-from',623 ]),624 ),625 diff3: (a, d) =>626 readOps(627 a,628 d,629 new Set([630 '-m',631 '-T',632 '-A',633 '-E',634 '-e',635 '-x',636 '-X',637 '-3',638 '-i',639 '--label',640 ]),641 ),642 sdiff: (a, d) =>643 readOps(644 a,645 d,646 new Set(['-o', '-w', '-W', '-s', '-i', '-b', '-B', '-E', '-H']),647 ),648 cmp: (a, d) =>649 readOps(650 a,651 d,652 new Set([653 '-i',654 '-l',655 '-n',656 '-s',657 '--ignore-initial',658 '--bytes',659 '--print-bytes',660 '--quiet',661 '--silent',662 '--verbose',663 '--zero',664 ]),665 ),666 md5sum: (a, d) => readOps(a, d),667 sha1sum: (a, d) => readOps(a, d),668 sha256sum: (a, d) => readOps(a, d),669 sha512sum: (a, d) => readOps(a, d),670 sha224sum: (a, d) => readOps(a, d),671 sha384sum: (a, d) => readOps(a, d),672 cksum: (a, d) => readOps(a, d),673 b2sum: (a, d) => readOps(a, d),674 sum: (a, d) => readOps(a, d),675 strings: (a, d) =>676 readOps(677 a,678 d,679 new Set([680 '-n',681 '-t',682 '-e',683 '-o',684 '-a',685 '--min-len',686 '--radix',687 '--encoding',688 '--file',689 '--print-file-name',690 '--data',691 '--all',692 ]),693 ),694 hexdump: (a, d) =>695 readOps(696 a,697 d,698 new Set([699 '-n',700 '-s',701 '-l',702 '-C',703 '-b',704 '-c',705 '-d',706 '-o',707 '-x',708 '-e',709 '-f',710 '-v',711 ]),712 ),713 xxd: (a, d) =>714 readOps(715 a,716 d,717 new Set([718 '-l',719 '-s',720 '-c',721 '-g',722 '-o',723 '-n',724 '-b',725 '-e',726 '-i',727 '-p',728 '-r',729 '-u',730 '-E',731 ]),732 ),733 od: (a, d) =>734 readOps(735 a,736 d,737 new Set([738 '-N',739 '-j',740 '-w',741 '-s',742 '-t',743 '-A',744 '-v',745 '--address-radix',746 '--endian',747 '--format',748 '--read-bytes',749 '--skip-bytes',750 '--strings',751 '--output-duplicates',752 '--width',753 ]),754 ),755 sort: (a, d) => {756 const output = writeOpForFlag(a, d, '-o', '--output');757 return [758 ...readOps(759 a,760 d,761 new Set([762 '-k',763 '-t',764 '-T',765 '--output',766 '-o',767 '--field-separator',768 '--key',769 '--temporary-directory',770 '--compress-program',771 '--batch-size',772 '--parallel',773 '--random-source',774 '--sort',775 ]),776 ),777 ...(output ? [output] : []),778 ];779 },780 uniq: (a, d) =>781 readOps(782 a,783 d,784 new Set([785 '-f',786 '-s',787 '-w',788 '-n',789 '--skip-fields',790 '--skip-chars',791 '--check-chars',792 ]),793 ),794 cut: (a, d) =>795 readOps(796 a,797 d,798 new Set([799 '-b',800 '-c',801 '-d',802 '-f',803 '--delimiter',804 '--fields',805 '--bytes',806 '--characters',807 '--output-delimiter',808 ]),809 ),810 paste: (a, d) =>811 readOps(a, d, new Set(['-d', '-s', '--delimiters', '--serial'])),812 join: (a, d) =>813 readOps(814 a,815 d,816 new Set([817 '-t',818 '-1',819 '-2',820 '-j',821 '-o',822 '-a',823 '-e',824 '--field',825 '--header',826 '--check-order',827 '--nocheck-order',828 '--zero-terminated',829 ]),830 ),831 column: (a, d) =>832 readOps(833 a,834 d,835 new Set([836 '-t',837 '-s',838 '-n',839 '-c',840 '-o',841 '-x',842 '--table',843 '--separator',844 '--output-separator',845 '--fillrows',846 ]),847 ),848 fold: (a, d) =>849 readOps(850 a,851 d,852 new Set(['-w', '-b', '-s', '--width', '--bytes', '--spaces']),853 ),854 expand: (a, d) => readOps(a, d, new Set(['-t', '--tabs', '--initial'])),855 unexpand: (a, d) =>856 readOps(a, d, new Set(['-t', '-a', '--tabs', '--all', '--first-only'])),857 base64: (a, d) =>858 readOps(859 a,860 d,861 new Set(['-d', '-i', '-w', '--decode', '--ignore-garbage', '--wrap']),862 ),863 base32: (a, d) =>864 readOps(865 a,866 d,867 new Set(['-d', '-i', '-w', '--decode', '--ignore-garbage', '--wrap']),868 ),869 tr: (a, d) => readOps(a, d),870 871 // ── Grep / search commands ────────────────────────────────────────────────872 873 grep: (args, cwd) => {874 const hasPatternFlag = args.some(875 (a) =>876 a === '-e' || a === '-f' || a.startsWith('-e') || a.startsWith('-f'),877 );878 const isRecursive = args.some((a) =>879 ['-r', '-R', '--recursive', '--dereference-recursive'].includes(a),880 );881 const flagsWithValue = new Set([882 '-e',883 '-f',884 '-m',885 '-A',886 '-B',887 '-C',888 '--context',889 '--include',890 '--exclude',891 '--exclude-dir',892 '--max-count',893 '--after-context',894 '--before-context',895 '-n',896 '--line-number',897 '--label',898 '-D',899 '--devices',900 '--max-depth',901 '-X',902 '--exclude-from',903 ]);904 const positional = getPositionalArgs(args, flagsWithValue).filter(905 looksLikePath,906 );907 // If -e/-f was used, there is no positional pattern; all positionals are paths.908 // Otherwise, the first positional is the pattern and the rest are paths.909 const filePaths = hasPatternFlag ? positional : positional.slice(1);910 const tool: 'read_file' | 'list_directory' = isRecursive911 ? 'list_directory'912 : 'read_file';913 return filePaths.map((p) => ({914 virtualTool: tool,915 filePath: resolvePath(p, cwd),916 }));917 },918 egrep: (a, d) => (COMMANDS['grep'] as CommandHandler)(a, d),919 fgrep: (a, d) => (COMMANDS['grep'] as CommandHandler)(a, d),920 zgrep: (a, d) => (COMMANDS['grep'] as CommandHandler)(a, d),921 bzgrep: (a, d) => (COMMANDS['grep'] as CommandHandler)(a, d),922 923 rg: (args, cwd) => {924 // ripgrep: recursive by default; first non-flag positional = pattern925 const hasPatternFlag = args.some((a) => a === '-e' || a === '-f');926 const flagsWithValue = new Set([927 '-e',928 '-f',929 '-m',930 '-A',931 '-B',932 '-C',933 '-t',934 '-T',935 '-g',936 '--iglob',937 '--glob',938 '--type',939 '--type-not',940 '--max-count',941 '--max-depth',942 '--context',943 '--after-context',944 '--before-context',945 '-M',946 '--max-columns',947 '--field-match-separator',948 ]);949 const positional = getPositionalArgs(args, flagsWithValue).filter(950 looksLikePath,951 );952 const filePaths = hasPatternFlag ? positional : positional.slice(1);953 return filePaths.map((p) => ({954 virtualTool: 'list_directory' as const,955 filePath: resolvePath(p, cwd),956 }));957 },958 959 ag: (args, cwd) => {960 const hasPatternFlag = args.some((a) => a === '-e');961 const flagsWithValue = new Set([962 '-e',963 '-m',964 '-A',965 '-B',966 '-C',967 '--depth',968 '--file-search-regex',969 '--file-search-regex-i',970 '--ignore',971 '--ignore-dir',972 '-n',973 ]);974 const positional = getPositionalArgs(args, flagsWithValue).filter(975 looksLikePath,976 );977 const filePaths = hasPatternFlag ? positional : positional.slice(1);978 return filePaths.map((p) => ({979 virtualTool: 'list_directory' as const,980 filePath: resolvePath(p, cwd),981 }));982 },983 984 ack: (args, cwd) => {985 const flagsWithValue = new Set([986 '-m',987 '-A',988 '-B',989 '-C',990 '--type',991 '--ignore-dir',992 '--ignore-file',993 '--ignore-directory',994 '-n',995 ]);996 // ack: first positional = pattern, rest = paths997 const positional = getPositionalArgs(args, flagsWithValue).filter(998 looksLikePath,999 );1000 return positional.slice(1).map((p) => ({1001 virtualTool: 'list_directory' as const,1002 filePath: resolvePath(p, cwd),1003 }));1004 },1005 1006 // ── Directory-listing commands ────────────────────────────────────────────1007 1008 ls: (a, d) => listOps(a, d),1009 dir: (a, d) => listOps(a, d),1010 vdir: (a, d) => listOps(a, d),1011 exa: (a, d) =>1012 listOps(1013 a,1014 d,1015 new Set([1016 '-L',1017 '--level',1018 '--sort',1019 '--color',1020 '--colour',1021 '--group',1022 '-I',1023 '--ignore-glob',1024 ]),1025 ),1026 eza: (a, d) =>1027 listOps(1028 a,1029 d,1030 new Set([1031 '-L',1032 '--level',1033 '--sort',1034 '--color',1035 '--colour',1036 '--group',1037 '-I',1038 '--ignore-glob',1039 ]),1040 ),1041 lsd: (a, d) =>1042 listOps(1043 a,1044 d,1045 new Set([1046 '--depth',1047 '--color',1048 '--icon',1049 '--icon-theme',1050 '--date',1051 '--size',1052 '--blocks',1053 '--header',1054 '--classic',1055 '--no-symlink',1056 '--ignore-glob',1057 '-I',1058 ]),1059 ),1060 1061 find: (args, cwd) => {1062 // `find [starting-point...] [expression]`1063 // Starting points come before any expression keyword beginning with `-` or `(`.1064 const expressionKeywords = new Set([1065 '-name',1066 '-iname',1067 '-path',1068 '-ipath',1069 '-regex',1070 '-iregex',1071 '-type',1072 '-maxdepth',1073 '-mindepth',1074 '-newer',1075 '-mtime',1076 '-atime',1077 '-ctime',1078 '-size',1079 '-user',1080 '-group',1081 '-perm',1082 '-links',1083 '-inum',1084 '-exec',1085 '-execdir',1086 '-ok',1087 '-okdir',1088 '-print',1089 '-print0',1090 '-ls',1091 '-delete',1092 '-prune',1093 '-depth',1094 '-empty',1095 '-readable',1096 '-writable',1097 '-executable',1098 '-follow',1099 '-xdev',1100 '-mount',1101 '-true',1102 '-false',1103 '-not',1104 '!',1105 '-a',1106 '-and',1107 '-o',1108 '-or',1109 ]);1110 const startingPoints: string[] = [];1111 for (const arg of args) {1112 if (1113 arg.startsWith('-') ||1114 arg === '(' ||1115 arg === ')' ||1116 expressionKeywords.has(arg)1117 )1118 break;1119 if (looksLikePath(arg)) startingPoints.push(resolvePath(arg, cwd));1120 }1121 if (startingPoints.length === 0) {1122 return [1123 { virtualTool: 'list_directory', filePath: cwd },1124 ...extractFindExecOps(args, cwd),1125 ];1126 }1127 return [1128 ...startingPoints.map((p) => ({1129 virtualTool: 'list_directory' as const,1130 filePath: p,1131 })),1132 ...extractFindExecOps(args, cwd),1133 ];1134 },1135 1136 tree: (args, cwd) =>1137 listOps(1138 args,1139 cwd,1140 new Set([1141 '-L',1142 '-P',1143 '-I',1144 '-o',1145 '-n',1146 '-H',1147 '-T',1148 '--charset',1149 '--filelimit',1150 '--matchdirs',1151 '--dirsfirst',1152 '-J',1153 '-X',1154 '--du',1155 '--si',1156 ]),1157 ),1158 1159 du: (args, cwd) =>1160 listOps(1161 args,1162 cwd,1163 new Set([1164 '-d',1165 '--max-depth',1166 '--threshold',1167 '-t',1168 '--block-size',1169 '-B',1170 '--time-style',1171 '--exclude',1172 '-X',1173 '--time',1174 '--output',1175 ]),1176 ),1177 1178 // ── File-write commands (create or overwrite) ─────────────────────────────1179 1180 touch: (args, cwd) =>1181 getPositionalArgs(1182 args,1183 new Set(['-t', '-r', '--reference', '--date', '-d', '--time']),1184 )1185 .filter(looksLikePath)1186 .map((p) => ({1187 virtualTool: 'write_file' as const,1188 filePath: resolvePath(p, cwd),1189 })),1190 1191 mkdir: (args, cwd) =>1192 getPositionalArgs(args, new Set(['-m', '--mode', '-Z', '--context']))1193 .filter(looksLikePath)1194 .map((p) => ({1195 virtualTool: 'write_file' as const,1196 filePath: resolvePath(p, cwd),1197 })),1198 1199 mkfifo: (args, cwd) =>1200 getPositionalArgs(args, new Set(['-m', '--mode', '-Z']))