"""Foreground result post-processing for the terminal tool. Everything that happens between ``env.execute`` returning and the JSON result leaving the tool: session-cwd dual-write, sudo failure handling, output transform hook, truncation, ANSI strip, redaction, exit-code notes and failure hints, spill-file redaction, verification evidence. Also owns the exit-code interpretation tables. Split out of tools/terminal_tool.py; lazy ``from tools.terminal_tool import ...`` keeps the origin module's monkeypatch points authoritative. """ import json import logging import os import re from pathlib import Path from typing import Any, Optional logger = logging.getLogger("tools.terminal_tool") # Signal-death notes keyed by signum, used for both ``-signum`` (subprocess) # and ``128+signum`` (shell) encodings. Curated, not exhaustive, so a # legitimate application exit code is never mislabeled; 130/SIGINT is owned # by the executor's interrupt-marker path and excluded. _SIGNAL_EXIT_NOTES: dict[int, str] = { 3: "SIGQUIT (quit from keyboard)", 4: "SIGILL (illegal instruction — corrupt binary or wrong architecture)", 6: "SIGABRT (abort — assertion failure, fatal runtime error, or glibc abort)", 7: "SIGBUS (bus error — misaligned or unmapped memory access)", 8: "SIGFPE (fatal arithmetic error, e.g. integer division by zero)", 9: "SIGKILL — often the kernel OOM killer on memory exhaustion, " "or an explicit kill -9", 11: "SIGSEGV (segmentation fault — the program crashed)", 13: "SIGPIPE (wrote to a closed pipe — e.g. output piped to a reader that exited)", 15: "SIGTERM (terminated — kill/timeout or shutdown requested it to stop)", 24: "SIGXCPU (CPU time limit exceeded)", 25: "SIGXFSZ (file size limit exceeded)", } def _interpret_signal_exit(exit_code: int) -> str | None: """Note for a signal-termination exit code, or None. Negative codes are definite (subprocess semantics); 128+signum is the shell convention and a program *can* exit 139 itself, so those notes hedge with "usually".""" if exit_code < 0: signum = -exit_code if signum == 2: # SIGINT — executor's interrupt-marker path owns it return None note = _SIGNAL_EXIT_NOTES.get(signum) if note: return f"Command terminated by signal {signum}: {note}" try: import signal as _signal name = _signal.Signals(signum).name except (ValueError, ImportError): name = f"signal {signum}" return f"Command terminated by {name} (signal {signum})" if exit_code > 128: signum = exit_code - 128 note = _SIGNAL_EXIT_NOTES.get(signum) if note: return ( f"Exit code {exit_code} usually means the command was " f"terminated by signal {signum}: {note}" ) return None _NO_MATCH = "No matches found (not an error)" _FILES_DIFFER = "Files differ (expected, not an error)" _COND_FALSE = "Condition evaluated to false (expected, not an error)" # Informational non-zero exit codes per base command. _EXIT_CODE_SEMANTICS: dict[str, dict[int, str]] = { "grep": {1: _NO_MATCH}, "egrep": {1: _NO_MATCH}, "fgrep": {1: _NO_MATCH}, "rg": {1: _NO_MATCH}, "ag": {1: _NO_MATCH}, "ack": {1: _NO_MATCH}, "diff": {1: _FILES_DIFFER}, "colordiff": {1: _FILES_DIFFER}, "find": {1: "Some directories were inaccessible (partial results may still be valid)"}, "test": {1: _COND_FALSE}, "[": {1: _COND_FALSE}, "curl": { 6: "Could not resolve host", 7: "Failed to connect to host", 22: "HTTP response code indicated error (e.g. 404, 500)", 28: "Operation timed out", }, "git": {1: "Non-zero exit (often normal — e.g. 'git diff' returns 1 when files differ)"}, } def _interpret_exit_code(command: str, exit_code: int) -> str | None: """Note for a non-zero exit code that is informational rather than an error (grep=1 "no matches", diff=1 "files differ", signal deaths), so the model doesn't burn turns investigating it. None when 0 or a real error. """ if exit_code == 0: return None signal_note = _interpret_signal_exit(exit_code) if signal_note is not None: return signal_note # The last command of a pipeline/chain determines the exit code. segments = re.split(r'\s*(?:\|\||&&|[|;])\s*', command) last_segment = (segments[-1] if segments else command).strip() # Base command name: first word that isn't a VAR=val assignment, basename'd. base_cmd = "" for w in last_segment.split(): if "=" in w and not w.startswith("-"): continue base_cmd = w.split("/")[-1] break return _EXIT_CODE_SEMANTICS.get(base_cmd, {}).get(exit_code) def finalize_foreground_result( *, command: str, result: dict, env: Any, env_type: str, effective_task_id: str, task_id: Optional[str], session_id: Optional[str], session_key: str, workdir: Optional[str], command_cwd: Optional[str], approval_note: Optional[str], ) -> str: """Turn a raw ``env.execute`` result into the tool's JSON result string.""" from tools.terminal_tool import ( _get_sudo_password_callback, _handle_sudo_failure, _in_delegated_child_context, _invalidate_cached_sudo_on_auth_failure, _sudo_wrong_password_failure, env_var_enabled, record_session_cwd, ) # Record the cwd this command finished in as THIS session's durable cwd — # but only when the command actually reported it (an interrupted/killed # command emits no marker, and env.cwd then holds another session's # directory), and never for a transient per-command ``workdir``, which # would hijack the session cwd for every later command. observed_cwd = None if (result or {}).get("cwd_observed"): # Prefer the result's own cwd; env.cwd is shared mutable compat state # kept as fallback for third-party providers. observed_cwd = (result or {}).get("cwd") or getattr(env, "cwd", None) if not workdir and observed_cwd: record_session_cwd(session_key, observed_cwd) output = result.get("output", "") returncode = result.get("returncode", 0) # Spill metadata: present only when output overflowed the capture window. spill_total_chars = result.get("output_total_chars") spill_file_path = result.get("full_output_path") output = _handle_sudo_failure(output, env_type) sudo_auth_failed = _sudo_wrong_password_failure(output) sudo_cache_cleared = _invalidate_cached_sudo_on_auth_failure( command, output ) if sudo_cache_cleared: has_sudo_prompt_callback = _get_sudo_password_callback() is not None can_reprompt = ( has_sudo_prompt_callback or env_var_enabled("HERMES_INTERACTIVE") ) and not _in_delegated_child_context() if can_reprompt: output += ( "\n\n⚠️ Sudo authentication failed — cached password " "cleared. You will be prompted again on the next sudo " "command." ) # Plugin output-transform seam (fail-open; first string result wins). # Replacements are still subject to the output limit below. try: from hermes_cli.lifecycle import invoke_hook hook_results = invoke_hook( "transform_terminal_output", command=command, output=output, returncode=returncode, task_id=effective_task_id or "", env_type=env_type, ) for hook_result in hook_results: if isinstance(hook_result, str): output = hook_result break except Exception: pass # Truncate keeping head (errors often appear early) and tail (most recent). from tools.tool_output_limits import get_max_bytes MAX_OUTPUT_CHARS = get_max_bytes() if len(output) > MAX_OUTPUT_CHARS: head_chars = int(MAX_OUTPUT_CHARS * 0.4) tail_chars = MAX_OUTPUT_CHARS - head_chars omitted = len(output) - head_chars - tail_chars truncated_notice = ( f"\n\n... [OUTPUT TRUNCATED - {omitted} chars omitted " f"out of {len(output)} total] ...\n\n" ) output = output[:head_chars] + truncated_notice + output[-tail_chars:] # Strip ANSI so the model never copies escapes into file writes. from tools.ansi_strip import strip_ansi output = strip_ansi(output) # Redact secrets; redact_terminal_output is command-aware (env-dump # commands get the KEY=value pass, source/config dumps skip it). from agent.redact import redact_terminal_output output = redact_terminal_output(output.strip(), command) if output else "" exit_note = _interpret_exit_code(command, returncode) # Recovery hints for well-known failure shapes (tools/terminal_hints.py); # on rc=0, warn when a pipeline tail / `|| echo` may mask an upstream # failure and the output carries strong failure indicators (advisory only). failure_hint = None if returncode != 0 and not exit_note: try: from tools.terminal_hints import annotate_failure failure_hint = annotate_failure(command, returncode, output) except Exception: failure_hint = None elif returncode == 0: try: from tools.terminal_hints import annotate_masked_success failure_hint = annotate_masked_success(command, output) except Exception: failure_hint = None result_dict = { "output": output, "exit_code": returncode, "error": None, } # cwd echo when the command changed directory (gated on the observation # flag above so an interrupted command can't echo another session's cwd). try: if observed_cwd and command_cwd and os.path.realpath(str(observed_cwd)) != os.path.realpath(str(command_cwd)): result_dict["cwd"] = str(observed_cwd) except Exception: pass # Spill handle so the model can read the omitted middle instead of # re-running. The collector wrote it raw; redact it with the same pass # so no secret persists unmasked on disk. if spill_file_path: try: _sp = Path(spill_file_path) raw_spill = _sp.read_text(encoding="utf-8", errors="replace") from tools.spill_safety import write_text_exclusive # lstat-checked unlink + exclusive create: the redacted copy can't # be diverted through a symlink planted since the collector's write. write_text_exclusive( _sp, redact_terminal_output(strip_ansi(raw_spill), command), private=True, overwrite=True, errors="replace", ) result_dict["output_total_chars"] = spill_total_chars result_dict["full_output_path"] = spill_file_path result_dict["truncation_note"] = ( "Output exceeded the capture window (head+tail shown). " f"Full output ({spill_total_chars:,} chars) saved to " f"{spill_file_path} — search it with search_files or page it " "with read_file instead of re-running the command." ) except Exception: logger.debug("spill redaction failed; dropping spill handle", exc_info=True) try: Path(spill_file_path).unlink() except OSError: pass try: from agent.verification_evidence import record_terminal_result evidence = record_terminal_result( command=command, cwd=command_cwd, session_id=session_id or task_id or effective_task_id or "default", exit_code=returncode, output=output, ) if evidence: result_dict["verification_evidence"] = { "status": evidence.get("status"), "kind": evidence.get("kind"), "scope": evidence.get("scope"), "canonical_command": evidence.get("canonical_command"), } except Exception: logger.debug("verification evidence recording failed", exc_info=True) if approval_note: # rc=130 is an interrupt only with the executor's marker — a command # can legitimately `exit 130` itself. An interrupted approved run keeps # the audit note but must never imply success. if returncode == 130 and "[Command interrupted]" in output: result_dict["approval"] = approval_note.rstrip(".") + ", then interrupted." else: result_dict["approval"] = approval_note if exit_note: result_dict["exit_code_meaning"] = exit_note if failure_hint: result_dict["hint"] = failure_hint if sudo_auth_failed: result_dict["sudo_auth_failed"] = True if sudo_cache_cleared: result_dict["sudo_cache_cleared"] = True return json.dumps(result_dict, ensure_ascii=False)