From 6f5d6b1f5b832dc8389b728bd25981e46471fba5 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Sun, 2 Aug 2026 14:03:41 -0700 Subject: [PATCH] feat(terminal): auto-save parser-limit-blocked payloads as runnable scripts Follow-up to the recovery-recipe commit on this branch, per review: instead of only TELLING the model to re-author the payload via write_file (2 turns), materialize the blocked command to ~/.hermes/cache/blocked-scripts/blocked-*.sh and point the recovery at it directly: 'saved to - review it, then run terminal(command="bash ")' (1 turn). Safety posture is unchanged or better: - Nothing is executed here; the file is only written. - The bash follow-up goes through the normal execution pipeline, including the referenced-script content guard, which inspects script files named in commands - the payload is MORE visible to policy than it was inline. - Genuine hardline blocks (destructive ops) never save anything (test-asserted). - Save failures fall back to the previous manual write_file recipe. - 7-day opportunistic cleanup of saved payloads. --- tests/tools/test_blocked_command_guidance.py | 45 ++++++++++- tools/approval.py | 78 +++++++++++++++++--- 2 files changed, 108 insertions(+), 15 deletions(-) diff --git a/tests/tools/test_blocked_command_guidance.py b/tests/tools/test_blocked_command_guidance.py index 324f08838f269..51378c6976f2b 100644 --- a/tests/tools/test_blocked_command_guidance.py +++ b/tests/tools/test_blocked_command_guidance.py @@ -7,21 +7,58 @@ from tools.terminal_tool import _foreground_background_guidance class TestParserLimitRecovery: - def test_parser_limit_block_has_recovery_recipe(self): - r = _hardline_block_result(_PARSER_LIMIT_DESCRIPTION) + def test_parser_limit_block_saves_payload_and_names_it(self, tmp_path, monkeypatch): + monkeypatch.setenv("HERMES_HOME", str(tmp_path / ".hermes")) + cmd = "python3 -c '" + "x = 1; " * 900 + "'" + r = _hardline_block_result(_PARSER_LIMIT_DESCRIPTION, cmd) assert r["approved"] is False assert "RECOVERY" in r["message"] + assert "blocked-scripts" in r["message"] + import re as _re + m = _re.search(r"saved to (\S+\.sh)", r["message"]) + assert m, r["message"] + from pathlib import Path + saved = Path(m.group(1)) + assert saved.exists() + body = saved.read_text() + assert cmd in body + assert body.startswith("#!/bin/bash") + assert f"bash {saved}" in r["message"] + + def test_save_failure_falls_back_to_manual_recipe(self, monkeypatch): + import tools.approval as ap + monkeypatch.setattr(ap, "_save_blocked_payload", lambda c: None) + r = _hardline_block_result(_PARSER_LIMIT_DESCRIPTION, "python3 -c 'x'") assert "write_file" in r["message"] assert "bash /path/script.sh" in r["message"] + def test_no_command_falls_back_to_manual_recipe(self): + r = _hardline_block_result(_PARSER_LIMIT_DESCRIPTION) + assert "RECOVERY" in r["message"] + assert "write_file" in r["message"] + def test_malformed_exec_block_has_recovery_recipe(self): r = _hardline_block_result(_MALFORMED_EXEC_DESCRIPTION) assert "RECOVERY" in r["message"] - def test_real_hardline_blocks_unchanged(self): - r = _hardline_block_result("recursive delete of root filesystem") + def test_real_hardline_blocks_unchanged(self, tmp_path, monkeypatch): + monkeypatch.setenv("HERMES_HOME", str(tmp_path / ".hermes")) + r = _hardline_block_result("recursive delete of root filesystem", "rm -rf --no-preserve-root /") assert "RECOVERY" not in r["message"] assert "unconditional blocklist" in r["message"] + # And nothing was saved for a genuine hardline block. + assert not (tmp_path / ".hermes" / "cache" / "blocked-scripts").exists() + + def test_old_saved_payloads_cleaned(self, tmp_path, monkeypatch): + monkeypatch.setenv("HERMES_HOME", str(tmp_path / ".hermes")) + import os + d = tmp_path / ".hermes" / "cache" / "blocked-scripts" + d.mkdir(parents=True) + stale = d / "blocked-1-dead.sh" + stale.write_text("old") + os.utime(stale, (1, 1)) + _hardline_block_result(_PARSER_LIMIT_DESCRIPTION, "python3 -c 'y'") + assert not stale.exists() class TestBackgroundGuidanceRecipes: diff --git a/tools/approval.py b/tools/approval.py index 451588a2cfa07..f0771b8727f52 100644 --- a/tools/approval.py +++ b/tools/approval.py @@ -585,7 +585,52 @@ def _user_deny_block_result(pattern: str) -> dict: } -def _hardline_block_result(description: str) -> dict: +def _save_blocked_payload(command: str) -> Optional[str]: + """Persist a parser-limit-blocked command as a runnable script. + + The parser-limit block fires on payload SIZE/shape, not on the + operation — the command itself is usually a legitimate script the + model inlined (heredoc, giant one-liner). Materialize it to a file so + the recovery is one turn (`bash `) instead of two (re-author via + write_file, then run). Saving is strictly safer than the hint-only + path: the file goes through the same execution pipeline as any other + script (including the referenced-script content guard), and nothing + is executed here. + + Returns the saved path, or None on any failure (the hint then falls + back to the manual write_file recipe). + """ + try: + from hermes_constants import get_hermes_home + import time as _time + import uuid as _uuid + script_dir = get_hermes_home() / "cache" / "blocked-scripts" + script_dir.mkdir(parents=True, exist_ok=True) + # Opportunistic cleanup: blocked payloads older than 7 days. + cutoff = _time.time() - 7 * 86400 + for old in script_dir.glob("blocked-*.sh"): + try: + if old.stat().st_mtime < cutoff: + old.unlink() + except OSError: + pass + path = script_dir / f"blocked-{int(_time.time())}-{_uuid.uuid4().hex[:8]}.sh" + path.write_text( + "#!/bin/bash\n" + "# Auto-saved by Hermes: this command exceeded the inline command\n" + "# parser limit and was blocked from direct execution. Review it,\n" + "# then run it via: bash " + str(path) + "\n" + + command + + ("\n" if not command.endswith("\n") else ""), + encoding="utf-8", errors="replace", + ) + return str(path) + except Exception: + logger.debug("failed to save blocked payload", exc_info=True) + return None + + +def _hardline_block_result(description: str, command: str = "") -> dict: """Build the standard block result for a hardline match.""" message = ( f"BLOCKED (hardline): {description}. " @@ -599,15 +644,26 @@ def _hardline_block_result(description: str) -> dict: # (heredoc script, base64 blob, one-line python -c program) — not a # genuinely forbidden operation. 198 occurrences in a 250k-call # production window, typically followed by blind rephrase retries. - # Tell the model the working alternative explicitly. + # Auto-save the payload as a runnable script and point at it; fall + # back to the manual write_file recipe when saving fails. if description in (_PARSER_LIMIT_DESCRIPTION, _MALFORMED_EXEC_DESCRIPTION): - message += ( - " RECOVERY: this block fires on oversized/unparseable inline " - "command payloads (heredocs, giant one-liners), not on the " - "operation itself. Write the script to a file with write_file, " - "then run it: terminal(command=\"bash /path/script.sh\") or " - "\"python3 /path/script.py\". Do not retry inline." - ) + saved = _save_blocked_payload(command) if command else None + if saved: + message += ( + " RECOVERY: this block fires on oversized/unparseable inline " + "command payloads (heredocs, giant one-liners), not on the " + f"operation itself. Your command was saved to {saved} — " + f"review it, then run: terminal(command=\"bash {saved}\"). " + "Do not retry inline." + ) + else: + message += ( + " RECOVERY: this block fires on oversized/unparseable inline " + "command payloads (heredocs, giant one-liners), not on the " + "operation itself. Write the script to a file with write_file, " + "then run it: terminal(command=\"bash /path/script.sh\") or " + "\"python3 /path/script.py\". Do not retry inline." + ) return { "approved": False, "hardline": True, @@ -3122,7 +3178,7 @@ def check_dangerous_command(command: str, env_type: str, is_hardline, hardline_desc = detect_hardline_command(command) if is_hardline: logger.warning("Hardline block: %s (command: %s)", hardline_desc, command[:200]) - return _hardline_block_result(hardline_desc) + return _hardline_block_result(hardline_desc, command) # User-defined deny rules (approvals.deny in config.yaml): like the # hardline floor, these fire BEFORE the yolo bypass — a deny rule is the @@ -3422,7 +3478,7 @@ def check_all_command_guards(command: str, env_type: str, is_hardline, hardline_desc = detect_hardline_command(command) if is_hardline: logger.warning("Hardline block: %s (command: %s)", hardline_desc, command[:200]) - return _hardline_block_result(hardline_desc) + return _hardline_block_result(hardline_desc, command) # == Sudo stdin guard == # Like the hardline floor above, this is unconditional: there is never a