sedValidation.ts 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684
  1. import type { ToolPermissionContext } from '../../Tool.js'
  2. import { splitCommand_DEPRECATED } from '../../utils/bash/commands.js'
  3. import { tryParseShellCommand } from '../../utils/bash/shellQuote.js'
  4. import type { PermissionResult } from '../../utils/permissions/PermissionResult.js'
  5. /**
  6. * Helper: Validate flags against an allowlist
  7. * Handles both single flags and combined flags (e.g., -nE)
  8. * @param flags Array of flags to validate
  9. * @param allowedFlags Array of allowed single-character and long flags
  10. * @returns true if all flags are valid, false otherwise
  11. */
  12. function validateFlagsAgainstAllowlist(
  13. flags: string[],
  14. allowedFlags: string[],
  15. ): boolean {
  16. for (const flag of flags) {
  17. // Handle combined flags like -nE or -Er
  18. if (flag.startsWith('-') && !flag.startsWith('--') && flag.length > 2) {
  19. // Check each character in combined flag
  20. for (let i = 1; i < flag.length; i++) {
  21. const singleFlag = '-' + flag[i]
  22. if (!allowedFlags.includes(singleFlag)) {
  23. return false
  24. }
  25. }
  26. } else {
  27. // Single flag or long flag
  28. if (!allowedFlags.includes(flag)) {
  29. return false
  30. }
  31. }
  32. }
  33. return true
  34. }
  35. /**
  36. * Pattern 1: Check if this is a line printing command with -n flag
  37. * Allows: sed -n 'N' | sed -n 'N,M' with optional -E, -r, -z flags
  38. * Allows semicolon-separated print commands like: sed -n '1p;2p;3p'
  39. * File arguments are ALLOWED for this pattern
  40. * @internal Exported for testing
  41. */
  42. export function isLinePrintingCommand(
  43. command: string,
  44. expressions: string[],
  45. ): boolean {
  46. const sedMatch = command.match(/^\s*sed\s+/)
  47. if (!sedMatch) return false
  48. const withoutSed = command.slice(sedMatch[0].length)
  49. const parseResult = tryParseShellCommand(withoutSed)
  50. if (!parseResult.success) return false
  51. const parsed = parseResult.tokens
  52. // Extract all flags
  53. const flags: string[] = []
  54. for (const arg of parsed) {
  55. if (typeof arg === 'string' && arg.startsWith('-') && arg !== '--') {
  56. flags.push(arg)
  57. }
  58. }
  59. // Validate flags - only allow -n, -E, -r, -z and their long forms
  60. const allowedFlags = [
  61. '-n',
  62. '--quiet',
  63. '--silent',
  64. '-E',
  65. '--regexp-extended',
  66. '-r',
  67. '-z',
  68. '--zero-terminated',
  69. '--posix',
  70. ]
  71. if (!validateFlagsAgainstAllowlist(flags, allowedFlags)) {
  72. return false
  73. }
  74. // Check if -n flag is present (required for Pattern 1)
  75. let hasNFlag = false
  76. for (const flag of flags) {
  77. if (flag === '-n' || flag === '--quiet' || flag === '--silent') {
  78. hasNFlag = true
  79. break
  80. }
  81. // Check in combined flags
  82. if (flag.startsWith('-') && !flag.startsWith('--') && flag.includes('n')) {
  83. hasNFlag = true
  84. break
  85. }
  86. }
  87. // Must have -n flag for Pattern 1
  88. if (!hasNFlag) {
  89. return false
  90. }
  91. // Must have at least one expression
  92. if (expressions.length === 0) {
  93. return false
  94. }
  95. // All expressions must be print commands (strict allowlist)
  96. // Allow semicolon-separated commands
  97. for (const expr of expressions) {
  98. const commands = expr.split(';')
  99. for (const cmd of commands) {
  100. if (!isPrintCommand(cmd.trim())) {
  101. return false
  102. }
  103. }
  104. }
  105. return true
  106. }
  107. /**
  108. * Helper: Check if a single command is a valid print command
  109. * STRICT ALLOWLIST - only these exact forms are allowed:
  110. * - p (print all)
  111. * - Np (print line N, where N is digits)
  112. * - N,Mp (print lines N through M)
  113. * Anything else (including w, W, e, E commands) is rejected.
  114. * @internal Exported for testing
  115. */
  116. export function isPrintCommand(cmd: string): boolean {
  117. if (!cmd) return false
  118. // Single strict regex that only matches allowed print commands
  119. // ^(?:\d+|\d+,\d+)?p$ matches: p, 1p, 123p, 1,5p, 10,200p
  120. return /^(?:\d+|\d+,\d+)?p$/.test(cmd)
  121. }
  122. /**
  123. * Pattern 2: Check if this is a substitution command
  124. * Allows: sed 's/pattern/replacement/flags' where flags are only: g, p, i, I, m, M, 1-9
  125. * When allowFileWrites is true, allows -i flag and file arguments for in-place editing
  126. * When allowFileWrites is false (default), requires stdout-only (no file arguments, no -i flag)
  127. * @internal Exported for testing
  128. */
  129. function isSubstitutionCommand(
  130. command: string,
  131. expressions: string[],
  132. hasFileArguments: boolean,
  133. options?: { allowFileWrites?: boolean },
  134. ): boolean {
  135. const allowFileWrites = options?.allowFileWrites ?? false
  136. // When not allowing file writes, must NOT have file arguments
  137. if (!allowFileWrites && hasFileArguments) {
  138. return false
  139. }
  140. const sedMatch = command.match(/^\s*sed\s+/)
  141. if (!sedMatch) return false
  142. const withoutSed = command.slice(sedMatch[0].length)
  143. const parseResult = tryParseShellCommand(withoutSed)
  144. if (!parseResult.success) return false
  145. const parsed = parseResult.tokens
  146. // Extract all flags
  147. const flags: string[] = []
  148. for (const arg of parsed) {
  149. if (typeof arg === 'string' && arg.startsWith('-') && arg !== '--') {
  150. flags.push(arg)
  151. }
  152. }
  153. // Validate flags based on mode
  154. // Base allowed flags for both modes
  155. const allowedFlags = ['-E', '--regexp-extended', '-r', '--posix']
  156. // When allowing file writes, also permit -i and --in-place
  157. if (allowFileWrites) {
  158. allowedFlags.push('-i', '--in-place')
  159. }
  160. if (!validateFlagsAgainstAllowlist(flags, allowedFlags)) {
  161. return false
  162. }
  163. // Must have exactly one expression
  164. if (expressions.length !== 1) {
  165. return false
  166. }
  167. const expr = expressions[0]!.trim()
  168. // STRICT ALLOWLIST: Must be exactly a substitution command starting with 's'
  169. // This rejects standalone commands like 'e', 'w file', etc.
  170. if (!expr.startsWith('s')) {
  171. return false
  172. }
  173. // Parse substitution: s/pattern/replacement/flags
  174. // Only allow / as delimiter (strict)
  175. const substitutionMatch = expr.match(/^s\/(.*?)$/)
  176. if (!substitutionMatch) {
  177. return false
  178. }
  179. const rest = substitutionMatch[1]!
  180. // Find the positions of / delimiters
  181. let delimiterCount = 0
  182. let lastDelimiterPos = -1
  183. let i = 0
  184. while (i < rest.length) {
  185. if (rest[i] === '\\') {
  186. // Skip escaped character
  187. i += 2
  188. continue
  189. }
  190. if (rest[i] === '/') {
  191. delimiterCount++
  192. lastDelimiterPos = i
  193. }
  194. i++
  195. }
  196. // Must have found exactly 2 delimiters (pattern and replacement)
  197. if (delimiterCount !== 2) {
  198. return false
  199. }
  200. // Extract flags (everything after the last delimiter)
  201. const exprFlags = rest.slice(lastDelimiterPos + 1)
  202. // Validate flags: only allow g, p, i, I, m, M, and optionally ONE digit 1-9
  203. const allowedFlagChars = /^[gpimIM]*[1-9]?[gpimIM]*$/
  204. if (!allowedFlagChars.test(exprFlags)) {
  205. return false
  206. }
  207. return true
  208. }
  209. /**
  210. * Checks if a sed command is allowed by the allowlist.
  211. * The allowlist patterns themselves are strict enough to reject dangerous operations.
  212. * @param command The sed command to check
  213. * @param options.allowFileWrites When true, allows -i flag and file arguments for substitution commands
  214. * @returns true if the command is allowed (matches allowlist and passes denylist check), false otherwise
  215. */
  216. export function sedCommandIsAllowedByAllowlist(
  217. command: string,
  218. options?: { allowFileWrites?: boolean },
  219. ): boolean {
  220. const allowFileWrites = options?.allowFileWrites ?? false
  221. // Extract sed expressions (content inside quotes where actual sed commands live)
  222. let expressions: string[]
  223. try {
  224. expressions = extractSedExpressions(command)
  225. } catch (_error) {
  226. // If parsing failed, treat as not allowed
  227. return false
  228. }
  229. // Check if sed command has file arguments
  230. const hasFileArguments = hasFileArgs(command)
  231. // Check if command matches allowlist patterns
  232. let isPattern1 = false
  233. let isPattern2 = false
  234. if (allowFileWrites) {
  235. // When allowing file writes, only check substitution commands (Pattern 2 variant)
  236. // Pattern 1 (line printing) doesn't need file writes
  237. isPattern2 = isSubstitutionCommand(command, expressions, hasFileArguments, {
  238. allowFileWrites: true,
  239. })
  240. } else {
  241. // Standard read-only mode: check both patterns
  242. isPattern1 = isLinePrintingCommand(command, expressions)
  243. isPattern2 = isSubstitutionCommand(command, expressions, hasFileArguments)
  244. }
  245. if (!isPattern1 && !isPattern2) {
  246. return false
  247. }
  248. // Pattern 2 does not allow semicolons (command separators)
  249. // Pattern 1 allows semicolons for separating print commands
  250. for (const expr of expressions) {
  251. if (isPattern2 && expr.includes(';')) {
  252. return false
  253. }
  254. }
  255. // Defense-in-depth: Even if allowlist matches, check denylist
  256. for (const expr of expressions) {
  257. if (containsDangerousOperations(expr)) {
  258. return false
  259. }
  260. }
  261. return true
  262. }
  263. /**
  264. * Check if a sed command has file arguments (not just stdin)
  265. * @internal Exported for testing
  266. */
  267. export function hasFileArgs(command: string): boolean {
  268. const sedMatch = command.match(/^\s*sed\s+/)
  269. if (!sedMatch) return false
  270. const withoutSed = command.slice(sedMatch[0].length)
  271. const parseResult = tryParseShellCommand(withoutSed)
  272. if (!parseResult.success) return true
  273. const parsed = parseResult.tokens
  274. try {
  275. let argCount = 0
  276. let hasEFlag = false
  277. for (let i = 0; i < parsed.length; i++) {
  278. const arg = parsed[i]
  279. // Handle both string arguments and glob patterns (like *.log)
  280. if (typeof arg !== 'string' && typeof arg !== 'object') continue
  281. // If it's a glob pattern, it counts as a file argument
  282. if (
  283. typeof arg === 'object' &&
  284. arg !== null &&
  285. 'op' in arg &&
  286. arg.op === 'glob'
  287. ) {
  288. return true
  289. }
  290. // Skip non-string arguments that aren't glob patterns
  291. if (typeof arg !== 'string') continue
  292. // Handle -e flag followed by expression
  293. if ((arg === '-e' || arg === '--expression') && i + 1 < parsed.length) {
  294. hasEFlag = true
  295. i++ // Skip the next argument since it's the expression
  296. continue
  297. }
  298. // Handle --expression=value format
  299. if (arg.startsWith('--expression=')) {
  300. hasEFlag = true
  301. continue
  302. }
  303. // Handle -e=value format (non-standard but defense in depth)
  304. if (arg.startsWith('-e=')) {
  305. hasEFlag = true
  306. continue
  307. }
  308. // Skip other flags
  309. if (arg.startsWith('-')) continue
  310. argCount++
  311. // If we used -e flags, ALL non-flag arguments are file arguments
  312. if (hasEFlag) {
  313. return true
  314. }
  315. // If we didn't use -e flags, the first non-flag argument is the sed expression,
  316. // so we need more than 1 non-flag argument to have file arguments
  317. if (argCount > 1) {
  318. return true
  319. }
  320. }
  321. return false
  322. } catch (_error) {
  323. return true // Assume dangerous if parsing fails
  324. }
  325. }
  326. /**
  327. * Extract sed expressions from command, ignoring flags and filenames
  328. * @param command Full sed command
  329. * @returns Array of sed expressions to check for dangerous operations
  330. * @throws Error if parsing fails
  331. * @internal Exported for testing
  332. */
  333. export function extractSedExpressions(command: string): string[] {
  334. const expressions: string[] = []
  335. // Calculate withoutSed by trimming off the first N characters (removing 'sed ')
  336. const sedMatch = command.match(/^\s*sed\s+/)
  337. if (!sedMatch) return expressions
  338. const withoutSed = command.slice(sedMatch[0].length)
  339. // Reject dangerous flag combinations like -ew, -eW, -ee, -we (combined -e/-w with dangerous commands)
  340. if (/-e[wWe]/.test(withoutSed) || /-w[eE]/.test(withoutSed)) {
  341. throw new Error('Dangerous flag combination detected')
  342. }
  343. // Use shell-quote to parse the arguments properly
  344. const parseResult = tryParseShellCommand(withoutSed)
  345. if (!parseResult.success) {
  346. // Malformed shell syntax - throw error to be caught by caller
  347. throw new Error(`Malformed shell syntax: ${(parseResult as { success: false; error: string }).error}`)
  348. }
  349. const parsed = parseResult.tokens
  350. try {
  351. let foundEFlag = false
  352. let foundExpression = false
  353. for (let i = 0; i < parsed.length; i++) {
  354. const arg = parsed[i]
  355. // Skip non-string arguments (like control operators)
  356. if (typeof arg !== 'string') continue
  357. // Handle -e flag followed by expression
  358. if ((arg === '-e' || arg === '--expression') && i + 1 < parsed.length) {
  359. foundEFlag = true
  360. const nextArg = parsed[i + 1]
  361. if (typeof nextArg === 'string') {
  362. expressions.push(nextArg)
  363. i++ // Skip the next argument since we consumed it
  364. }
  365. continue
  366. }
  367. // Handle --expression=value format
  368. if (arg.startsWith('--expression=')) {
  369. foundEFlag = true
  370. expressions.push(arg.slice('--expression='.length))
  371. continue
  372. }
  373. // Handle -e=value format (non-standard but defense in depth)
  374. if (arg.startsWith('-e=')) {
  375. foundEFlag = true
  376. expressions.push(arg.slice('-e='.length))
  377. continue
  378. }
  379. // Skip other flags
  380. if (arg.startsWith('-')) continue
  381. // If we haven't found any -e flags, the first non-flag argument is the sed expression
  382. if (!foundEFlag && !foundExpression) {
  383. expressions.push(arg)
  384. foundExpression = true
  385. continue
  386. }
  387. // If we've already found -e flags or a standalone expression,
  388. // remaining non-flag arguments are filenames
  389. break
  390. }
  391. } catch (error) {
  392. // If shell-quote parsing fails, treat the sed command as unsafe
  393. throw new Error(
  394. `Failed to parse sed command: ${error instanceof Error ? error.message : 'Unknown error'}`,
  395. )
  396. }
  397. return expressions
  398. }
  399. /**
  400. * Check if a sed expression contains dangerous operations (denylist)
  401. * @param expression Single sed expression (without quotes)
  402. * @returns true if dangerous, false if safe
  403. */
  404. function containsDangerousOperations(expression: string): boolean {
  405. const cmd = expression.trim()
  406. if (!cmd) return false
  407. // CONSERVATIVE REJECTIONS: Broadly reject patterns that could be dangerous
  408. // When in doubt, treat as unsafe
  409. // Reject non-ASCII characters (Unicode homoglyphs, combining chars, etc.)
  410. // Examples: w (fullwidth), ᴡ (small capital), w̃ (combining tilde)
  411. // Check for characters outside ASCII range (0x01-0x7F, excluding null byte)
  412. // eslint-disable-next-line no-control-regex
  413. if (/[^\x01-\x7F]/.test(cmd)) {
  414. return true
  415. }
  416. // Reject curly braces (blocks) - too complex to parse
  417. if (cmd.includes('{') || cmd.includes('}')) {
  418. return true
  419. }
  420. // Reject newlines - multi-line commands are too complex
  421. if (cmd.includes('\n')) {
  422. return true
  423. }
  424. // Reject comments (# not immediately after s command)
  425. // Comments look like: #comment or start with #
  426. // Delimiter looks like: s#pattern#replacement#
  427. const hashIndex = cmd.indexOf('#')
  428. if (hashIndex !== -1 && !(hashIndex > 0 && cmd[hashIndex - 1] === 's')) {
  429. return true
  430. }
  431. // Reject negation operator
  432. // Negation can appear: at start (!/pattern/), after address (/pattern/!, 1,10!, $!)
  433. // Delimiter looks like: s!pattern!replacement! (has 's' before it)
  434. if (/^!/.test(cmd) || /[/\d$]!/.test(cmd)) {
  435. return true
  436. }
  437. // Reject tilde in GNU step address format (digit~digit, ,~digit, or $~digit)
  438. // Allow whitespace around tilde
  439. if (/\d\s*~\s*\d|,\s*~\s*\d|\$\s*~\s*\d/.test(cmd)) {
  440. return true
  441. }
  442. // Reject comma at start (bare comma is shorthand for 1,$ address range)
  443. if (/^,/.test(cmd)) {
  444. return true
  445. }
  446. // Reject comma followed by +/- (GNU offset addresses)
  447. if (/,\s*[+-]/.test(cmd)) {
  448. return true
  449. }
  450. // Reject backslash tricks:
  451. // 1. s\ (substitution with backslash delimiter)
  452. // 2. \X where X could be an alternate delimiter (|, #, %, etc.) - not regex escapes
  453. if (/s\\/.test(cmd) || /\\[|#%@]/.test(cmd)) {
  454. return true
  455. }
  456. // Reject escaped slashes followed by w/W (patterns like /\/path\/to\/file/w)
  457. if (/\\\/.*[wW]/.test(cmd)) {
  458. return true
  459. }
  460. // Reject malformed/suspicious patterns we don't understand
  461. // If there's a slash followed by non-slash chars, then whitespace, then dangerous commands
  462. // Examples: /pattern w file, /pattern e cmd, /foo X;w file
  463. if (/\/[^/]*\s+[wWeE]/.test(cmd)) {
  464. return true
  465. }
  466. // Reject malformed substitution commands that don't follow normal pattern
  467. // Examples: s/foobareoutput.txt (missing delimiters), s/foo/bar//w (extra delimiter)
  468. if (/^s\//.test(cmd) && !/^s\/[^/]*\/[^/]*\/[^/]*$/.test(cmd)) {
  469. return true
  470. }
  471. // PARANOID: Reject any command starting with 's' that ends with dangerous chars (w, W, e, E)
  472. // and doesn't match our known safe substitution pattern. This catches malformed s commands
  473. // with non-slash delimiters that might be trying to use dangerous flags.
  474. if (/^s./.test(cmd) && /[wWeE]$/.test(cmd)) {
  475. // Check if it's a properly formed substitution (any delimiter, not just /)
  476. const properSubst = /^s([^\\\n]).*?\1.*?\1[^wWeE]*$/.test(cmd)
  477. if (!properSubst) {
  478. return true
  479. }
  480. }
  481. // Check for dangerous write commands
  482. // Patterns: [address]w filename, [address]W filename, /pattern/w filename, /pattern/W filename
  483. // Simplified to avoid exponential backtracking (CodeQL issue)
  484. // Check for w/W in contexts where it would be a command (with optional whitespace)
  485. if (
  486. /^[wW]\s*\S+/.test(cmd) || // At start: w file
  487. /^\d+\s*[wW]\s*\S+/.test(cmd) || // After line number: 1w file or 1 w file
  488. /^\$\s*[wW]\s*\S+/.test(cmd) || // After $: $w file or $ w file
  489. /^\/[^/]*\/[IMim]*\s*[wW]\s*\S+/.test(cmd) || // After pattern: /pattern/w file
  490. /^\d+,\d+\s*[wW]\s*\S+/.test(cmd) || // After range: 1,10w file
  491. /^\d+,\$\s*[wW]\s*\S+/.test(cmd) || // After range: 1,$w file
  492. /^\/[^/]*\/[IMim]*,\/[^/]*\/[IMim]*\s*[wW]\s*\S+/.test(cmd) // After pattern range: /s/,/e/w file
  493. ) {
  494. return true
  495. }
  496. // Check for dangerous execute commands
  497. // Patterns: [address]e [command], /pattern/e [command], or commands starting with e
  498. // Simplified to avoid exponential backtracking (CodeQL issue)
  499. // Check for e in contexts where it would be a command (with optional whitespace)
  500. if (
  501. /^e/.test(cmd) || // At start: e cmd
  502. /^\d+\s*e/.test(cmd) || // After line number: 1e or 1 e
  503. /^\$\s*e/.test(cmd) || // After $: $e or $ e
  504. /^\/[^/]*\/[IMim]*\s*e/.test(cmd) || // After pattern: /pattern/e
  505. /^\d+,\d+\s*e/.test(cmd) || // After range: 1,10e
  506. /^\d+,\$\s*e/.test(cmd) || // After range: 1,$e
  507. /^\/[^/]*\/[IMim]*,\/[^/]*\/[IMim]*\s*e/.test(cmd) // After pattern range: /s/,/e/e
  508. ) {
  509. return true
  510. }
  511. // Check for substitution commands with dangerous flags
  512. // Pattern: s<delim>pattern<delim>replacement<delim>flags where flags contain w or e
  513. // Per POSIX, sed allows any character except backslash and newline as delimiter
  514. const substitutionMatch = cmd.match(/s([^\\\n]).*?\1.*?\1(.*?)$/)
  515. if (substitutionMatch) {
  516. const flags = substitutionMatch[2] || ''
  517. // Check for write flag: s/old/new/w filename or s/old/new/gw filename
  518. if (flags.includes('w') || flags.includes('W')) {
  519. return true
  520. }
  521. // Check for execute flag: s/old/new/e or s/old/new/ge
  522. if (flags.includes('e') || flags.includes('E')) {
  523. return true
  524. }
  525. }
  526. // Check for y (transliterate) command followed by dangerous operations
  527. // Pattern: y<delim>source<delim>dest<delim> followed by anything
  528. // The y command uses same delimiter syntax as s command
  529. // PARANOID: Reject any y command that has w/W/e/E anywhere after the delimiters
  530. const yCommandMatch = cmd.match(/y([^\\\n])/)
  531. if (yCommandMatch) {
  532. // If we see a y command, check if there's any w, W, e, or E in the entire command
  533. // This is paranoid but safe - y commands are rare and w/e after y is suspicious
  534. if (/[wWeE]/.test(cmd)) {
  535. return true
  536. }
  537. }
  538. return false
  539. }
  540. /**
  541. * Cross-cutting validation step for sed commands.
  542. *
  543. * This is a constraint check that blocks dangerous sed operations regardless of mode.
  544. * It returns 'passthrough' for non-sed commands or safe sed commands,
  545. * and 'ask' for dangerous sed operations (w/W/e/E commands).
  546. *
  547. * @param input - Object containing the command string
  548. * @param toolPermissionContext - Context containing mode and permissions
  549. * @returns
  550. * - 'ask' if any sed command contains dangerous operations
  551. * - 'passthrough' if no sed commands or all are safe
  552. */
  553. export function checkSedConstraints(
  554. input: { command: string },
  555. toolPermissionContext: ToolPermissionContext,
  556. ): PermissionResult {
  557. const commands = splitCommand_DEPRECATED(input.command)
  558. for (const cmd of commands) {
  559. // Skip non-sed commands
  560. const trimmed = cmd.trim()
  561. const baseCmd = trimmed.split(/\s+/)[0]
  562. if (baseCmd !== 'sed') {
  563. continue
  564. }
  565. // In acceptEdits mode, allow file writes (-i flag) but still block dangerous operations
  566. const allowFileWrites = toolPermissionContext.mode === 'acceptEdits'
  567. const isAllowed = sedCommandIsAllowedByAllowlist(trimmed, {
  568. allowFileWrites,
  569. })
  570. if (!isAllowed) {
  571. return {
  572. behavior: 'ask',
  573. message:
  574. 'sed command requires approval (contains potentially dangerous operations)',
  575. decisionReason: {
  576. type: 'other',
  577. reason:
  578. 'sed command contains operations that require explicit approval (e.g., write commands, execute commands)',
  579. },
  580. }
  581. }
  582. }
  583. // No dangerous sed commands found (or no sed commands at all)
  584. return {
  585. behavior: 'passthrough',
  586. message: 'No dangerous sed operations detected',
  587. }
  588. }