|
11 | 11 | MAX_INLINE_OUTPUT_LINES = 200 |
12 | 12 | DEFAULT_READ_LIMIT = 200 |
13 | 13 | MAX_LINE_LENGTH = 10000 # Max characters per line to prevent context explosion |
| 14 | +MAX_COMMAND_TIMEOUT = 300 # Hard cap (seconds) for an agent run_command call |
| 15 | + |
| 16 | +# Catastrophic command patterns refused by run_command as defense-in-depth. Commands |
| 17 | +# run on the user's own machine against their own project, but a fixing agent should |
| 18 | +# never need these, and refusing them guards against an accidental destructive call. |
| 19 | +_DESTRUCTIVE_COMMAND_PATTERNS = ( |
| 20 | + "rm -rf /", |
| 21 | + "rm -rf /*", |
| 22 | + "rm -rf ~", |
| 23 | + "mkfs", |
| 24 | + "shutdown", |
| 25 | + "reboot", |
| 26 | + "dd if=", |
| 27 | + ":(){", |
| 28 | + "> /dev/sd", |
| 29 | + "of=/dev/sd", |
| 30 | +) |
14 | 31 |
|
15 | 32 |
|
16 | 33 | def run_unit_tests(args: dict, render_context: RenderContext) -> str: |
@@ -433,6 +450,156 @@ def _get_linked_resource_paths(render_context: RenderContext) -> list[str]: |
433 | 450 | return paths |
434 | 451 |
|
435 | 452 |
|
| 453 | +def _rejects_full_conformance_suite(command: str, render_context: RenderContext) -> str | None: |
| 454 | + """Refuse commands that invoke the full conformance test suite. |
| 455 | +
|
| 456 | + The full suite is expensive and is run automatically when the agent submits its |
| 457 | + fix, so the agent never needs to run it itself. Steer it toward targeted |
| 458 | + diagnostics instead. (The unit test script is cheaper and not guarded.) |
| 459 | + """ |
| 460 | + script = render_context.conformance_tests_script |
| 461 | + if not script: |
| 462 | + return None |
| 463 | + candidates = {script, os.path.normpath(script), os.path.basename(script)} |
| 464 | + if any(c and c in command for c in candidates): |
| 465 | + return ( |
| 466 | + "Error: Running the full conformance test suite via run_command is not allowed — it is " |
| 467 | + "expensive and runs automatically when you submit your fix. Use run_command for targeted " |
| 468 | + "diagnostics instead: reproduce the failing case directly (run a single function or a small " |
| 469 | + "snippet), inspect intermediate values, or probe a specific endpoint." |
| 470 | + ) |
| 471 | + return None |
| 472 | + |
| 473 | + |
| 474 | +def _rejects_destructive_command(command: str) -> str | None: |
| 475 | + """Refuse obviously catastrophic commands (defense-in-depth).""" |
| 476 | + lowered = command.lower() |
| 477 | + for pattern in _DESTRUCTIVE_COMMAND_PATTERNS: |
| 478 | + if pattern in lowered: |
| 479 | + return f"Error: command refused as potentially destructive (matched '{pattern}')." |
| 480 | + return None |
| 481 | + |
| 482 | + |
| 483 | +def _snapshot_folder_files(folders: list[str]) -> set[str]: |
| 484 | + """Capture the set of file paths currently under the given folders.""" |
| 485 | + snapshot: set[str] = set() |
| 486 | + for folder in folders: |
| 487 | + if not os.path.isdir(folder): |
| 488 | + continue |
| 489 | + for root, _dirs, names in os.walk(folder): |
| 490 | + for name in names: |
| 491 | + snapshot.add(os.path.join(root, name)) |
| 492 | + return snapshot |
| 493 | + |
| 494 | + |
| 495 | +def _remove_files_created_since(folders: list[str], before: set[str]) -> int: |
| 496 | + """Delete files under the given folders that were not present in the before-snapshot. |
| 497 | +
|
| 498 | + Used to undo the file side effects of run_command (e.g. compiled .class files and |
| 499 | + other build artifacts produced by `mvn test` and the like) so they never leak into |
| 500 | + git diffs, per-FRID commits, or the existing-files content passed to agents. Only |
| 501 | + newly-created files are removed; pre-existing files — including the agent's own |
| 502 | + in-progress edits — are left untouched. |
| 503 | + """ |
| 504 | + removed = 0 |
| 505 | + for folder in folders: |
| 506 | + if not os.path.isdir(folder): |
| 507 | + continue |
| 508 | + for root, _dirs, names in os.walk(folder): |
| 509 | + for name in names: |
| 510 | + path = os.path.join(root, name) |
| 511 | + if path not in before: |
| 512 | + try: |
| 513 | + os.remove(path) |
| 514 | + removed += 1 |
| 515 | + except OSError: |
| 516 | + pass |
| 517 | + return removed |
| 518 | + |
| 519 | + |
| 520 | +def run_command(args: dict, render_context: RenderContext) -> str: |
| 521 | + """Run an arbitrary shell command for diagnostics and return its combined output. |
| 522 | +
|
| 523 | + The command runs from the project root. To run from elsewhere, prefix it with a cd |
| 524 | + (e.g. ``cd build && ...``). There is no working-directory restriction — a shell |
| 525 | + command can reach anywhere on the machine regardless, so a cwd guard would be |
| 526 | + meaningless; the only real guards are the suite/destructive refusals below. |
| 527 | +
|
| 528 | + Any files the command creates under the build or conformance test folders are |
| 529 | + removed afterwards, so build artifacts (e.g. compiled .class files from `mvn test`) |
| 530 | + don't pollute diffs, commits, or the files passed to agents. Write scratch files to |
| 531 | + /tmp if you need them to persist. |
| 532 | +
|
| 533 | + Args: |
| 534 | + args: Dictionary containing: |
| 535 | + - command (str, required): The shell command to run. |
| 536 | + - timeout (int, optional): Seconds before the command is killed. Defaults to |
| 537 | + the standard command timeout, capped at MAX_COMMAND_TIMEOUT. |
| 538 | + render_context: The current render context. |
| 539 | +
|
| 540 | + Returns: |
| 541 | + The exit code plus combined stdout/stderr (truncated, with the full output |
| 542 | + spilled to a temp file when large), or an error description. |
| 543 | + """ |
| 544 | + command = args.get("command", "") |
| 545 | + if not command or not command.strip(): |
| 546 | + return "Error: command is required" |
| 547 | + |
| 548 | + suite_rejection = _rejects_full_conformance_suite(command, render_context) |
| 549 | + if suite_rejection: |
| 550 | + return suite_rejection |
| 551 | + |
| 552 | + destructive_rejection = _rejects_destructive_command(command) |
| 553 | + if destructive_rejection: |
| 554 | + return destructive_rejection |
| 555 | + |
| 556 | + cwd = _get_project_root() |
| 557 | + |
| 558 | + # Resolve the timeout (capped). |
| 559 | + timeout = args.get("timeout") |
| 560 | + if timeout is not None: |
| 561 | + try: |
| 562 | + timeout = min(int(timeout), MAX_COMMAND_TIMEOUT) |
| 563 | + except (ValueError, TypeError): |
| 564 | + timeout = render_utils.COMMAND_EXECUTION_TIMEOUT |
| 565 | + else: |
| 566 | + timeout = render_utils.COMMAND_EXECUTION_TIMEOUT |
| 567 | + |
| 568 | + # Snapshot the tracked folders so any files the command creates (build artifacts |
| 569 | + # like compiled .class files) can be removed afterwards. These would otherwise be |
| 570 | + # picked up by git diffs / commits and leak into the context passed to agents. |
| 571 | + artifact_folders = _get_allowed_write_folders(render_context) |
| 572 | + files_before = _snapshot_folder_files(artifact_folders) |
| 573 | + try: |
| 574 | + exit_code, output, temp_file_path = render_utils.execute_command( |
| 575 | + command, |
| 576 | + cwd=cwd, |
| 577 | + timeout=timeout, |
| 578 | + stop_event=render_context.stop_event, |
| 579 | + ) |
| 580 | + except Exception as e: |
| 581 | + return f"Error running command: {type(e).__name__}: {e}" |
| 582 | + finally: |
| 583 | + _remove_files_created_since(artifact_folders, files_before) |
| 584 | + |
| 585 | + header = f"Command exited with code {exit_code} (cwd: {cwd})." |
| 586 | + if not output: |
| 587 | + return f"{header}\n(no output)" |
| 588 | + |
| 589 | + lines = output.split("\n") |
| 590 | + if len(lines) <= MAX_INLINE_OUTPUT_LINES: |
| 591 | + return f"{header}\n{output}" |
| 592 | + |
| 593 | + truncated = "\n".join(lines[:MAX_INLINE_OUTPUT_LINES]) |
| 594 | + if temp_file_path: |
| 595 | + return ( |
| 596 | + f"{header} Output truncated ({len(lines)} total lines). " |
| 597 | + f"Full output available at: {temp_file_path}\n" |
| 598 | + f'Use read_file with file_path="{temp_file_path}" to see the complete output.\n\n{truncated}' |
| 599 | + ) |
| 600 | + return f"{header} Output truncated ({len(lines)} total lines).\n{truncated}" |
| 601 | + |
| 602 | + |
436 | 603 | def read_file(args: dict, render_context: RenderContext) -> str: |
437 | 604 | file_path = args.get("file_path", "") |
438 | 605 | offset = args.get("offset") |
|
0 commit comments