diff --git a/src/content/authors/victordelpuerto.json b/src/content/authors/victordelpuerto.json new file mode 100644 index 0000000..ecfbfee --- /dev/null +++ b/src/content/authors/victordelpuerto.json @@ -0,0 +1,10 @@ +{ + "name": "Victor Del Puerto", + "bio": "Runs DG Ingeniería, an engineering firm in Paraguay, and builds and measures the runtime controls its AI coding agents work under.", + "role": "contributor", + "website": "https://victordelpuerto.com", + "github": "VDP89", + "twitter": "vdp2007", + "linkedin": "https://www.linkedin.com/in/victordelpuertog/", + "email": "info@dgingenieriasrl.com" +} diff --git a/src/content/posts/functional-scars-from-repeated-agent-errors-to-runtime-controls/.write-source.json b/src/content/posts/functional-scars-from-repeated-agent-errors-to-runtime-controls/.write-source.json new file mode 100644 index 0000000..061c444 --- /dev/null +++ b/src/content/posts/functional-scars-from-repeated-agent-errors-to-runtime-controls/.write-source.json @@ -0,0 +1,3296 @@ +{ + "kind": "mlsys-write-source", + "version": 1, + "meta": { + "title": "Functional Scars: From Repeated Agent Errors to Runtime Controls", + "summary": "On Windows, Claude Code's Bash tool halves runs of backslashes before bash reads the command, except runs right before a double quote. Our coding agent kept breaking Python files it patched through shell heredocs, even with the trap written into its memory. This walkthrough turns that correction into a hook, then measures what changed across 102,298 shell calls, including what the block cost.", + "authors": [ + "victordelpuerto" + ], + "writerName": "Author Name", + "topicId": "agents", + "topicName": "Agents", + "tags": [ + "agents", + "hooks", + "claude-code", + "runtime-enforcement", + "evaluation" + ], + "slug": "functional-scars-from-repeated-agent-errors-to-runtime-controls", + "coverFileName": "", + "ogCard": false, + "draft": false, + "proposedTopic": "", + "newAuthor": { + "handle": "victordelpuerto", + "name": "Victor Del Puerto", + "bio": "Runs DG Ingeniería, an engineering firm in Paraguay, and builds and measures the runtime controls its AI coding agents work under.", + "website": "https://victordelpuerto.com", + "github": "VDP89", + "twitter": "vdp2007", + "linkedin": "https://www.linkedin.com/in/victordelpuertog/", + "email": "info@dgingenieriasrl.com" + }, + "featured": false, + "date": "2026-10-05" + }, + "blocks": [ + { + "id": "paragraph-1", + "type": "paragraph", + "props": { + "backgroundColor": "default", + "textColor": "default", + "textAlignment": "left" + }, + "content": [ + { + "type": "text", + "text": "On 10 September 2026, in a single working session, four patches that the coding agent in my engineering office made to Python files failed the same way. Each one went through a shell heredoc, ", + "styles": {} + }, + { + "type": "text", + "text": "python - <<'PYEOF' ... PYEOF", + "styles": { + "code": true + } + }, + { + "type": "text", + "text": ". Two wrote files that no longer parsed (", + "styles": {} + }, + { + "type": "text", + "text": "SyntaxError: unterminated string literal", + "styles": { + "code": true + } + }, + { + "type": "text", + "text": "); the other two aborted because the text they were meant to find had changed on its way in. A note in the agent’s memory, written the day before, already described the trap. Across the transcripts from 9 to 11 September, the same error after a heredoc that wrote a ", + "styles": {} + }, + { + "type": "text", + "text": ".py", + "styles": { + "code": true + } + }, + { + "type": "text", + "text": " file appeared 8 times in 6 sessions.", + "styles": {} + } + ], + "children": [] + }, + { + "id": "paragraph-2", + "type": "paragraph", + "props": { + "backgroundColor": "default", + "textColor": "default", + "textAlignment": "left" + }, + "content": [ + { + "type": "text", + "text": "The shortcut kept coming back because it usually works: in that session the agent ran 43 heredoc commands that touched a ", + "styles": {} + }, + { + "type": "text", + "text": ".py", + "styles": { + "code": true + } + }, + { + "type": "text", + "text": " file, and 39 did what they were meant to do. Each success is evidence for the shortcut, and a note in memory competes against that evidence every time the agent edits a file.", + "styles": {} + } + ], + "children": [] + }, + { + "id": "heading-3", + "type": "heading", + "props": { + "backgroundColor": "default", + "textColor": "default", + "textAlignment": "left", + "level": 1, + "isToggleable": false + }, + "content": [ + { + "type": "text", + "text": "The mechanism", + "styles": {} + } + ], + "children": [] + }, + { + "id": "paragraph-4", + "type": "paragraph", + "props": { + "backgroundColor": "default", + "textColor": "default", + "textAlignment": "left" + }, + "content": [ + { + "type": "text", + "text": "In Claude Code’s Bash tool on Windows 11 with Git Bash, this command:", + "styles": {} + } + ], + "children": [] + }, + { + "id": "codeBlock-5", + "type": "codeBlock", + "props": { + "language": "bash" + }, + "content": [ + { + "type": "text", + "text": "echo 'a\\\\b | a\\\\\\\\b | a\\nb'", + "styles": {} + } + ], + "children": [] + }, + { + "id": "paragraph-6", + "type": "paragraph", + "props": { + "backgroundColor": "default", + "textColor": "default", + "textAlignment": "left" + }, + "content": [ + { + "type": "text", + "text": "prints ", + "styles": {} + }, + { + "type": "text", + "text": "a\\b | a\\\\b | a\\nb", + "styles": { + "code": true + } + }, + { + "type": "text", + "text": ". Single quotes should keep every backslash. Runs of one, two, three and four backslashes arrive as one, one, two and two, and a run right before a double quote arrives intact. An open issue on the Claude Code repository documents the cause [1]: Claude Code builds the command line with MSVCRT quoting rules, which double backslashes only in front of a quote, and Git Bash, an MSYS2 program, halves every run when it decodes the line. I have not tested other platforms.", + "styles": {} + } + ], + "children": [] + }, + { + "id": "paragraph-7", + "type": "paragraph", + "props": { + "backgroundColor": "default", + "textColor": "default", + "textAlignment": "left" + }, + "content": [ + { + "type": "text", + "text": "A patch written inside a heredoc goes through three levels of escaping, and the agent writes it for two. To put the escape ", + "styles": {} + }, + { + "type": "text", + "text": "\\n", + "styles": { + "code": true + } + }, + { + "type": "text", + "text": " into a string of the destination file, the agent writes ", + "styles": {} + }, + { + "type": "text", + "text": "\\\\n", + "styles": { + "code": true + } + }, + { + "type": "text", + "text": " in the patch program. After the transport the program holds ", + "styles": {} + }, + { + "type": "text", + "text": "\\n", + "styles": { + "code": true + } + }, + { + "type": "text", + "text": ", writes a real newline into the destination’s string literal, and the destination stops parsing, although the command exits cleanly. Other combinations break the patch program itself, such as ", + "styles": {} + }, + { + "type": "text", + "text": "'\\\\'", + "styles": { + "code": true + } + }, + { + "type": "text", + "text": " arriving as ", + "styles": {} + }, + { + "type": "text", + "text": "'\\'", + "styles": { + "code": true + } + }, + { + "type": "text", + "text": ".", + "styles": {} + } + ], + "children": [] + }, + { + "id": "heading-8", + "type": "heading", + "props": { + "backgroundColor": "default", + "textColor": "default", + "textAlignment": "left", + "level": 1, + "isToggleable": false + }, + "content": [ + { + "type": "text", + "text": "The correction as a contract", + "styles": {} + } + ], + "children": [] + }, + { + "id": "paragraph-9", + "type": "paragraph", + "props": { + "backgroundColor": "default", + "textColor": "default", + "textAlignment": "left" + }, + "content": [ + { + "type": "text", + "text": "I call the control that came out of this a ", + "styles": {} + }, + { + "type": "text", + "text": "functional scar", + "styles": { + "italic": true + } + }, + { + "type": "text", + "text": ": a versioned check, derived from a documented failure, that runs outside the model at a named event of the agent runtime [2]. NeMo Guardrails evaluates programmable rails at runtime [3], AgentSpec specifies triggers, predicates and enforcement actions [4], and TRACE compiles a user’s chat corrections into checks that run before a coding agent finishes a task [5]. The contract keeps a rule’s origin and evidence attached to the check, so the rule can be measured and retired:", + "styles": {} + } + ], + "children": [] + }, + { + "id": "table-10", + "type": "table", + "props": { + "textColor": "default" + }, + "content": { + "type": "tableContent", + "columnWidths": [ + null, + null, + null + ], + "rows": [ + { + "cells": [ + { + "type": "tableCell", + "content": [ + { + "type": "text", + "text": "Field", + "styles": {} + } + ], + "props": { + "colspan": 1, + "rowspan": 1, + "backgroundColor": "default", + "textColor": "default", + "textAlignment": "left" + } + }, + { + "type": "tableCell", + "content": [ + { + "type": "text", + "text": "What it states", + "styles": {} + } + ], + "props": { + "colspan": 1, + "rowspan": 1, + "backgroundColor": "default", + "textColor": "default", + "textAlignment": "left" + } + }, + { + "type": "tableCell", + "content": [ + { + "type": "text", + "text": "This correction", + "styles": {} + } + ], + "props": { + "colspan": 1, + "rowspan": 1, + "backgroundColor": "default", + "textColor": "default", + "textAlignment": "left" + } + } + ] + }, + { + "cells": [ + { + "type": "tableCell", + "content": [ + { + "type": "text", + "text": "Origin", + "styles": {} + } + ], + "props": { + "colspan": 1, + "rowspan": 1, + "backgroundColor": "default", + "textColor": "default", + "textAlignment": "left" + } + }, + { + "type": "tableCell", + "content": [ + { + "type": "text", + "text": "The documented failure and its correction", + "styles": {} + } + ], + "props": { + "colspan": 1, + "rowspan": 1, + "backgroundColor": "default", + "textColor": "default", + "textAlignment": "left" + } + }, + { + "type": "tableCell", + "content": [ + { + "type": "text", + "text": "Four failed patches on 10 September; 8 in 6 sessions over three days. Correction: write ", + "styles": {} + }, + { + "type": "text", + "text": ".py", + "styles": { + "code": true + } + }, + { + "type": "text", + "text": " files with the editor tools, never through a heredoc", + "styles": {} + } + ], + "props": { + "colspan": 1, + "rowspan": 1, + "backgroundColor": "default", + "textColor": "default", + "textAlignment": "left" + } + } + ] + }, + { + "cells": [ + { + "type": "tableCell", + "content": [ + { + "type": "text", + "text": "Applicability", + "styles": {} + } + ], + "props": { + "colspan": 1, + "rowspan": 1, + "backgroundColor": "default", + "textColor": "default", + "textAlignment": "left" + } + }, + { + "type": "tableCell", + "content": [ + { + "type": "text", + "text": "When the correction applies", + "styles": {} + } + ], + "props": { + "colspan": 1, + "rowspan": 1, + "backgroundColor": "default", + "textColor": "default", + "textAlignment": "left" + } + }, + { + "type": "tableCell", + "content": [ + { + "type": "text", + "text": "A Bash command contains a heredoc and writes a ", + "styles": {} + }, + { + "type": "text", + "text": ".py", + "styles": { + "code": true + } + }, + { + "type": "text", + "text": " file, through a shell redirect or through Python code in the heredoc body", + "styles": {} + } + ], + "props": { + "colspan": 1, + "rowspan": 1, + "backgroundColor": "default", + "textColor": "default", + "textAlignment": "left" + } + } + ] + }, + { + "cells": [ + { + "type": "tableCell", + "content": [ + { + "type": "text", + "text": "Obligation", + "styles": {} + } + ], + "props": { + "colspan": 1, + "rowspan": 1, + "backgroundColor": "default", + "textColor": "default", + "textAlignment": "left" + } + }, + { + "type": "tableCell", + "content": [ + { + "type": "text", + "text": "What must be observable when it applies", + "styles": {} + } + ], + "props": { + "colspan": 1, + "rowspan": 1, + "backgroundColor": "default", + "textColor": "default", + "textAlignment": "left" + } + }, + { + "type": "tableCell", + "content": [ + { + "type": "text", + "text": "Every ", + "styles": {} + }, + { + "type": "text", + "text": ".py", + "styles": { + "code": true + } + }, + { + "type": "text", + "text": " file the agent writes contains the text the agent wrote, and still parses", + "styles": {} + } + ], + "props": { + "colspan": 1, + "rowspan": 1, + "backgroundColor": "default", + "textColor": "default", + "textAlignment": "left" + } + } + ] + }, + { + "cells": [ + { + "type": "tableCell", + "content": [ + { + "type": "text", + "text": "Intervention", + "styles": {} + } + ], + "props": { + "colspan": 1, + "rowspan": 1, + "backgroundColor": "default", + "textColor": "default", + "textAlignment": "left" + } + }, + { + "type": "tableCell", + "content": [ + { + "type": "text", + "text": "Host event and response", + "styles": {} + } + ], + "props": { + "colspan": 1, + "rowspan": 1, + "backgroundColor": "default", + "textColor": "default", + "textAlignment": "left" + } + }, + { + "type": "tableCell", + "content": [ + { + "type": "text", + "text": "PreToolUse", + "styles": { + "code": true + } + }, + { + "type": "text", + "text": " on Bash: deny, with the reason and the alternative. ", + "styles": {} + }, + { + "type": "text", + "text": "PostToolUse", + "styles": { + "code": true + } + }, + { + "type": "text", + "text": " on Bash: parse the ", + "styles": {} + }, + { + "type": "text", + "text": ".py", + "styles": { + "code": true + } + }, + { + "type": "text", + "text": " files the command touched and report any that fail", + "styles": {} + } + ], + "props": { + "colspan": 1, + "rowspan": 1, + "backgroundColor": "default", + "textColor": "default", + "textAlignment": "left" + } + } + ] + }, + { + "cells": [ + { + "type": "tableCell", + "content": [ + { + "type": "text", + "text": "Evidence", + "styles": {} + } + ], + "props": { + "colspan": 1, + "rowspan": 1, + "backgroundColor": "default", + "textColor": "default", + "textAlignment": "left" + } + }, + { + "type": "tableCell", + "content": [ + { + "type": "text", + "text": "What each firing records", + "styles": {} + } + ], + "props": { + "colspan": 1, + "rowspan": 1, + "backgroundColor": "default", + "textColor": "default", + "textAlignment": "left" + } + }, + { + "type": "tableCell", + "content": [ + { + "type": "text", + "text": "Event, rule, decision, hash of the command, session", + "styles": {} + } + ], + "props": { + "colspan": 1, + "rowspan": 1, + "backgroundColor": "default", + "textColor": "default", + "textAlignment": "left" + } + } + ] + }, + { + "cells": [ + { + "type": "tableCell", + "content": [ + { + "type": "text", + "text": "Review", + "styles": {} + } + ], + "props": { + "colspan": 1, + "rowspan": 1, + "backgroundColor": "default", + "textColor": "default", + "textAlignment": "left" + } + }, + { + "type": "tableCell", + "content": [ + { + "type": "text", + "text": "Who changes or retires the rule, and on what evidence", + "styles": {} + } + ], + "props": { + "colspan": 1, + "rowspan": 1, + "backgroundColor": "default", + "textColor": "default", + "textAlignment": "left" + } + }, + { + "type": "tableCell", + "content": [ + { + "type": "text", + "text": "A test suite built from real commands; false activations; retirement when the transport bug is fixed upstream", + "styles": {} + } + ], + "props": { + "colspan": 1, + "rowspan": 1, + "backgroundColor": "default", + "textColor": "default", + "textAlignment": "left" + } + } + ] + } + ] + }, + "children": [] + }, + { + "id": "paragraph-11", + "type": "paragraph", + "props": { + "backgroundColor": "default", + "textColor": "default", + "textAlignment": "left" + }, + "content": [ + { + "type": "text", + "text": "The intervention has two arms because the predicate can miss: the first acts on the command before the transport touches it, the second looks at what reached the disk.", + "styles": {} + } + ], + "children": [] + }, + { + "id": "mermaid-12", + "type": "mermaid", + "props": { + "source": "flowchart TD\n A[\"Agent proposes a Bash command\"] --> P{\"PreToolUse: heredoc that writes a .py?\"}\n P -->|\"yes\"| D[\"Deny, with the reason and the alternative\"]\n D --> W[\"Agent writes the file with the editor tool\"]\n P -->|\"no\"| T[\"Transport to Git Bash halves backslash runs\"]\n T --> S[\"bash runs the command\"]\n S --> Q{\"PostToolUse: do the .py files it named still parse?\"}\n Q -->|\"no\"| C[\"Context to the agent: file, line, error\"]\n Q -->|\"yes\"| N[\"Silent\"]", + "svg": "yesnonoyesAgent proposes aBash commandPreToolUse: heredocthat writes a .py?Deny, with thereason and thealternativeAgent writes thefile with the editortoolTransport to GitBash halvesbackslash runsbash runs thecommandPostToolUse: do the.py files it namedstill parse?Context to theagent: file, line,errorSilent", + "caption": "", + "width": "" + }, + "children": [] + }, + { + "id": "heading-13", + "type": "heading", + "props": { + "backgroundColor": "default", + "textColor": "default", + "textAlignment": "left", + "level": 1, + "isToggleable": false + }, + "content": [ + { + "type": "text", + "text": "A minimal version with plain hooks", + "styles": {} + } + ], + "children": [] + }, + { + "id": "paragraph-14", + "type": "paragraph", + "props": { + "backgroundColor": "default", + "textColor": "default", + "textAlignment": "left" + }, + "content": [ + { + "type": "text", + "text": "Claude Code runs hooks on ", + "styles": {} + }, + { + "type": "text", + "text": "PreToolUse", + "styles": { + "code": true + } + }, + { + "type": "text", + "text": " and ", + "styles": {} + }, + { + "type": "text", + "text": "PostToolUse", + "styles": { + "code": true + } + }, + { + "type": "text", + "text": " for tool calls that match a pattern, and passes the event as JSON on standard input [6]. A ", + "styles": {} + }, + { + "type": "text", + "text": "PreToolUse", + "styles": { + "code": true + } + }, + { + "type": "text", + "text": " hook denies a call by printing ", + "styles": {} + }, + { + "type": "text", + "text": "permissionDecision: \"deny\"", + "styles": { + "code": true + } + }, + { + "type": "text", + "text": "; a ", + "styles": {} + }, + { + "type": "text", + "text": "PostToolUse", + "styles": { + "code": true + } + }, + { + "type": "text", + "text": " hook adds context for the model with ", + "styles": {} + }, + { + "type": "text", + "text": "additionalContext", + "styles": { + "code": true + } + }, + { + "type": "text", + "text": ". Registration in ", + "styles": {} + }, + { + "type": "text", + "text": ".claude/settings.json", + "styles": { + "code": true + } + }, + { + "type": "text", + "text": ":", + "styles": {} + } + ], + "children": [] + }, + { + "id": "codeBlock-15", + "type": "codeBlock", + "props": { + "language": "json" + }, + "content": [ + { + "type": "text", + "text": "{\n \"hooks\": {\n \"PreToolUse\": [\n { \"matcher\": \"Bash\",\n \"hooks\": [ { \"type\": \"command\", \"command\": \"python3 \\\"${CLAUDE_PROJECT_DIR}/.claude/hooks/heredoc_guard.py\\\"\" } ] }\n ],\n \"PostToolUse\": [\n { \"matcher\": \"Bash\",\n \"hooks\": [ { \"type\": \"command\", \"command\": \"python3 \\\"${CLAUDE_PROJECT_DIR}/.claude/hooks/heredoc_guard.py\\\"\" } ] }\n ]\n }\n}", + "styles": {} + } + ], + "children": [] + }, + { + "id": "paragraph-16", + "type": "paragraph", + "props": { + "backgroundColor": "default", + "textColor": "default", + "textAlignment": "left" + }, + "content": [ + { + "type": "text", + "text": "The script keeps shell text and heredoc bodies apart, so a body that only mentions ", + "styles": {} + }, + { + "type": "text", + "text": "app.py", + "styles": { + "code": true + } + }, + { + "type": "text", + "text": " is not a write. Before a call it denies a redirect into a ", + "styles": {} + }, + { + "type": "text", + "text": ".py", + "styles": { + "code": true + } + }, + { + "type": "text", + "text": " file or a Python heredoc that opens one for writing; after any call it parses the ", + "styles": {} + }, + { + "type": "text", + "text": ".py", + "styles": { + "code": true + } + }, + { + "type": "text", + "text": " files the command named that changed in the last two minutes. Standard library only, tested with Python 3.13:", + "styles": {} + } + ], + "children": [] + }, + { + "id": "codeBlock-17", + "type": "codeBlock", + "props": { + "language": "python" + }, + "content": [ + { + "type": "text", + "text": "#!/usr/bin/env python3\n\"\"\"Claude Code Bash hook. PreToolUse: deny a heredoc that writes a .py file.\nPostToolUse: parse every .py file the command named and that changed in the last two minutes.\"\"\"\nimport ast\nimport json\nimport re\nimport sys\nimport time\nfrom pathlib import Path\n\nHEREDOC = re.compile(r\"<<-?\\s*['\\\"]?(\\w+)['\\\"]?\")\nREDIRECT_PY = re.compile(r\"(?:>>?|\\btee(?:\\s+-a)?)\\s*['\\\"]?[^\\s'\\\"<>|;&]+\\.py\\b\")\nPY_WRITE = re.compile(r\"open\\(\\s*['\\\"][^'\\\"]+\\.py['\\\"]\\s*,\\s*['\\\"][wax]\"\n r\"|Path\\(\\s*['\\\"][^'\\\"]+\\.py['\\\"]\\s*\\)\\.write_text\")\nPY_NAME = re.compile(r\"[\\w./\\\\-]+\\.py\\b\")\n\n\ndef split(cmd):\n \"\"\"Shell text and heredoc bodies, kept apart: a body that mentions x.py is not a redirect.\"\"\"\n shell, bodies, lines, i = [], [], cmd.split(\"\\n\"), 0\n while i < len(lines):\n shell.append(lines[i])\n opener = HEREDOC.search(lines[i])\n i += 1\n if opener:\n body = []\n while i < len(lines) and lines[i].strip() != opener.group(1):\n body.append(lines[i])\n i += 1\n bodies.append(\"\\n\".join(body))\n i += 1\n return \"\\n\".join(shell), bodies\n\n\ndef writes_py_by_heredoc(cmd):\n shell, bodies = split(cmd)\n if not bodies:\n return False\n if REDIRECT_PY.search(shell):\n return True\n return \"python\" in shell and any(PY_WRITE.search(body) for body in bodies)\n\n\ndef broken_py(cmd, cwd):\n broken = []\n for name in sorted(set(PY_NAME.findall(cmd))):\n path = Path(cwd, name)\n if path.is_file() and time.time() - path.stat().st_mtime < 120:\n try:\n ast.parse(path.read_text(encoding=\"utf-8\"))\n except SyntaxError as err:\n broken.append(f\"{name} line {err.lineno}: {err.msg}\")\n return broken\n\n\ndef main():\n event = json.load(sys.stdin)\n cmd = (event.get(\"tool_input\") or {}).get(\"command\") or \"\"\n if event.get(\"hook_event_name\") == \"PreToolUse\" and writes_py_by_heredoc(cmd):\n reason = (\"This heredoc writes a .py file. On Windows the Bash tool halves runs of backslashes \"\n \"(except before a double quote) before bash reads the command, so escapes in string \"\n \"literals can change. Use the Write tool.\")\n print(json.dumps({\"hookSpecificOutput\": {\n \"hookEventName\": \"PreToolUse\",\n \"permissionDecision\": \"deny\",\n \"permissionDecisionReason\": reason}}))\n elif event.get(\"hook_event_name\") == \"PostToolUse\":\n broken = broken_py(cmd, event.get(\"cwd\") or \".\")\n if broken:\n print(json.dumps({\"hookSpecificOutput\": {\n \"hookEventName\": \"PostToolUse\",\n \"additionalContext\": \"A .py file no longer parses: \" + \"; \".join(broken)}}))\n\n\nif __name__ == \"__main__\":\n main()", + "styles": {} + } + ], + "children": [] + }, + { + "id": "paragraph-18", + "type": "paragraph", + "props": { + "backgroundColor": "default", + "textColor": "default", + "textAlignment": "left" + }, + "content": [ + { + "type": "text", + "text": "Run through standard input the way the host runs it:", + "styles": {} + } + ], + "children": [] + }, + { + "id": "table-19", + "type": "table", + "props": { + "textColor": "default" + }, + "content": { + "type": "tableContent", + "columnWidths": [ + null, + null + ], + "rows": [ + { + "cells": [ + { + "type": "tableCell", + "content": [ + { + "type": "text", + "text": "Event and command", + "styles": {} + } + ], + "props": { + "colspan": 1, + "rowspan": 1, + "backgroundColor": "default", + "textColor": "default", + "textAlignment": "left" + } + }, + { + "type": "tableCell", + "content": [ + { + "type": "text", + "text": "Result", + "styles": {} + } + ], + "props": { + "colspan": 1, + "rowspan": 1, + "backgroundColor": "default", + "textColor": "default", + "textAlignment": "left" + } + } + ] + }, + { + "cells": [ + { + "type": "tableCell", + "content": [ + { + "type": "text", + "text": "Pre: ", + "styles": {} + }, + { + "type": "text", + "text": "cat > fix.py <<'EOF'", + "styles": { + "code": true + } + }, + { + "type": "text", + "text": " with a ", + "styles": {} + }, + { + "type": "text", + "text": "\\\\n", + "styles": { + "code": true + } + }, + { + "type": "text", + "text": " in a string", + "styles": {} + } + ], + "props": { + "colspan": 1, + "rowspan": 1, + "backgroundColor": "default", + "textColor": "default", + "textAlignment": "left" + } + }, + { + "type": "tableCell", + "content": [ + { + "type": "text", + "text": "Denied", + "styles": {} + } + ], + "props": { + "colspan": 1, + "rowspan": 1, + "backgroundColor": "default", + "textColor": "default", + "textAlignment": "left" + } + } + ] + }, + { + "cells": [ + { + "type": "tableCell", + "content": [ + { + "type": "text", + "text": "Pre: ", + "styles": {} + }, + { + "type": "text", + "text": "python - <<'EOF'", + "styles": { + "code": true + } + }, + { + "type": "text", + "text": " whose body calls ", + "styles": {} + }, + { + "type": "text", + "text": "Path('app.py').write_text(...)", + "styles": { + "code": true + } + } + ], + "props": { + "colspan": 1, + "rowspan": 1, + "backgroundColor": "default", + "textColor": "default", + "textAlignment": "left" + } + }, + { + "type": "tableCell", + "content": [ + { + "type": "text", + "text": "Denied", + "styles": {} + } + ], + "props": { + "colspan": 1, + "rowspan": 1, + "backgroundColor": "default", + "textColor": "default", + "textAlignment": "left" + } + } + ] + }, + { + "cells": [ + { + "type": "tableCell", + "content": [ + { + "type": "text", + "text": "Pre: ", + "styles": {} + }, + { + "type": "text", + "text": "python - <<'EOF'", + "styles": { + "code": true + } + }, + { + "type": "text", + "text": " whose body only reads ", + "styles": {} + }, + { + "type": "text", + "text": "app.py", + "styles": { + "code": true + } + } + ], + "props": { + "colspan": 1, + "rowspan": 1, + "backgroundColor": "default", + "textColor": "default", + "textAlignment": "left" + } + }, + { + "type": "tableCell", + "content": [ + { + "type": "text", + "text": "Allowed", + "styles": {} + } + ], + "props": { + "colspan": 1, + "rowspan": 1, + "backgroundColor": "default", + "textColor": "default", + "textAlignment": "left" + } + } + ] + }, + { + "cells": [ + { + "type": "tableCell", + "content": [ + { + "type": "text", + "text": "Pre: ", + "styles": {} + }, + { + "type": "text", + "text": "cat > notes.md <<'EOF'", + "styles": { + "code": true + } + }, + { + "type": "text", + "text": " whose text mentions ", + "styles": {} + }, + { + "type": "text", + "text": "open('app.py', 'w')", + "styles": { + "code": true + } + } + ], + "props": { + "colspan": 1, + "rowspan": 1, + "backgroundColor": "default", + "textColor": "default", + "textAlignment": "left" + } + }, + { + "type": "tableCell", + "content": [ + { + "type": "text", + "text": "Allowed", + "styles": {} + } + ], + "props": { + "colspan": 1, + "rowspan": 1, + "backgroundColor": "default", + "textColor": "default", + "textAlignment": "left" + } + } + ] + }, + { + "cells": [ + { + "type": "tableCell", + "content": [ + { + "type": "text", + "text": "Pre: ", + "styles": {} + }, + { + "type": "text", + "text": "python fix.py", + "styles": { + "code": true + } + } + ], + "props": { + "colspan": 1, + "rowspan": 1, + "backgroundColor": "default", + "textColor": "default", + "textAlignment": "left" + } + }, + { + "type": "tableCell", + "content": [ + { + "type": "text", + "text": "Allowed", + "styles": {} + } + ], + "props": { + "colspan": 1, + "rowspan": 1, + "backgroundColor": "default", + "textColor": "default", + "textAlignment": "left" + } + } + ] + }, + { + "cells": [ + { + "type": "tableCell", + "content": [ + { + "type": "text", + "text": "Post: ", + "styles": {} + }, + { + "type": "text", + "text": "sed -i ... app.py", + "styles": { + "code": true + } + }, + { + "type": "text", + "text": " that left a string split by a real newline", + "styles": {} + } + ], + "props": { + "colspan": 1, + "rowspan": 1, + "backgroundColor": "default", + "textColor": "default", + "textAlignment": "left" + } + }, + { + "type": "tableCell", + "content": [ + { + "type": "text", + "text": "Context added: ", + "styles": {} + }, + { + "type": "text", + "text": "app.py line 1: unterminated string literal (detected at line 1)", + "styles": { + "code": true + } + } + ], + "props": { + "colspan": 1, + "rowspan": 1, + "backgroundColor": "default", + "textColor": "default", + "textAlignment": "left" + } + } + ] + }, + { + "cells": [ + { + "type": "tableCell", + "content": [ + { + "type": "text", + "text": "Post: ", + "styles": {} + }, + { + "type": "text", + "text": "sed -i ... ok.py", + "styles": { + "code": true + } + }, + { + "type": "text", + "text": " that left valid code", + "styles": {} + } + ], + "props": { + "colspan": 1, + "rowspan": 1, + "backgroundColor": "default", + "textColor": "default", + "textAlignment": "left" + } + }, + { + "type": "tableCell", + "content": [ + { + "type": "text", + "text": "Silent", + "styles": {} + } + ], + "props": { + "colspan": 1, + "rowspan": 1, + "backgroundColor": "default", + "textColor": "default", + "textAlignment": "left" + } + } + ] + } + ] + }, + "children": [] + }, + { + "id": "paragraph-20", + "type": "paragraph", + "props": { + "backgroundColor": "default", + "textColor": "default", + "textAlignment": "left" + }, + "content": [ + { + "type": "text", + "text": "Two mutants of the hook, one without heredoc detection and one without the redirect check, both fail these cases. Codex exposes the same events with a deny response [7]; I have not tested whether its shell path has the bug. The deployed version, 253 lines, covers more write forms and is tested against 19 real commands that must be blocked and 18 that must pass.", + "styles": {} + } + ], + "children": [] + }, + { + "id": "heading-21", + "type": "heading", + "props": { + "backgroundColor": "default", + "textColor": "default", + "textAlignment": "left", + "level": 1, + "isToggleable": false + }, + "content": [ + { + "type": "text", + "text": "The trigger was wrong on the first day", + "styles": {} + } + ], + "children": [] + }, + { + "id": "paragraph-22", + "type": "paragraph", + "props": { + "backgroundColor": "default", + "textColor": "default", + "textAlignment": "left" + }, + "content": [ + { + "type": "text", + "text": "The first version of the hook recognized three literal forms of writing a ", + "styles": {} + }, + { + "type": "text", + "text": ".py", + "styles": { + "code": true + } + }, + { + "type": "text", + "text": " file. A cold review by a second agent found that none of the six forms in the incident transcript was among them: the hook would have passed the commands that motivated it. The fix went in 77 minutes after the first commit. Minutes before that commit it had also blocked a heredoc that wrote a Markdown index naming ", + "styles": {} + }, + { + "type": "text", + "text": ".py", + "styles": { + "code": true + } + }, + { + "type": "text", + "text": " paths, so the ", + "styles": {} + }, + { + "type": "text", + "text": ".py", + "styles": { + "code": true + } + }, + { + "type": "text", + "text": " file now has to be the target of the write.", + "styles": {} + } + ], + "children": [] + }, + { + "id": "paragraph-23", + "type": "paragraph", + "props": { + "backgroundColor": "default", + "textColor": "default", + "textAlignment": "left" + }, + "content": [ + { + "type": "text", + "text": "Two sibling rules built in the following days went the same way. One, for a ", + "styles": {} + }, + { + "type": "text", + "text": "python -", + "styles": { + "code": true + } + }, + { + "type": "text", + "text": " heredoc with ", + "styles": {} + }, + { + "type": "text", + "text": " +{/* mermaid +flowchart TD + A["Agent proposes a Bash command"] --> P{"PreToolUse: heredoc that writes a .py?"} + P -->|"yes"| D["Deny, with the reason and the alternative"] + D --> W["Agent writes the file with the editor tool"] + P -->|"no"| T["Transport to Git Bash halves backslash runs"] + T --> S["bash runs the command"] + S --> Q{"PostToolUse: do the .py files it named still parse?"} + Q -->|"no"| C["Context to the agent: file, line, error"] + Q -->|"yes"| N["Silent"] +*/} +yesnonoyesAgent proposes aBash commandPreToolUse: heredocthat writes a .py?Deny, with thereason and thealternativeAgent writes thefile with the editortoolTransport to GitBash halvesbackslash runsbash runs thecommandPostToolUse: do the.py files it namedstill parse?Context to theagent: file, line,errorSilent + + +## A minimal version with plain hooks + +Claude Code runs hooks on `PreToolUse` and `PostToolUse` for tool calls that match a pattern, and passes the event as JSON on standard input \[6]. A `PreToolUse` hook denies a call by printing `permissionDecision: "deny"`; a `PostToolUse` hook adds context for the model with `additionalContext`. Registration in `.claude/settings.json`: + +```json +{ + "hooks": { + "PreToolUse": [ + { "matcher": "Bash", + "hooks": [ { "type": "command", "command": "python3 \"${CLAUDE_PROJECT_DIR}/.claude/hooks/heredoc_guard.py\"" } ] } + ], + "PostToolUse": [ + { "matcher": "Bash", + "hooks": [ { "type": "command", "command": "python3 \"${CLAUDE_PROJECT_DIR}/.claude/hooks/heredoc_guard.py\"" } ] } + ] + } +} +``` + +The script keeps shell text and heredoc bodies apart, so a body that only mentions `app.py` is not a write. Before a call it denies a redirect into a `.py` file or a Python heredoc that opens one for writing; after any call it parses the `.py` files the command named that changed in the last two minutes. Standard library only, tested with Python 3.13: + +```python +#!/usr/bin/env python3 +"""Claude Code Bash hook. PreToolUse: deny a heredoc that writes a .py file. +PostToolUse: parse every .py file the command named and that changed in the last two minutes.""" +import ast +import json +import re +import sys +import time +from pathlib import Path + +HEREDOC = re.compile(r"<<-?\s*['\"]?(\w+)['\"]?") +REDIRECT_PY = re.compile(r"(?:>>?|\btee(?:\s+-a)?)\s*['\"]?[^\s'\"<>|;&]+\.py\b") +PY_WRITE = re.compile(r"open\(\s*['\"][^'\"]+\.py['\"]\s*,\s*['\"][wax]" + r"|Path\(\s*['\"][^'\"]+\.py['\"]\s*\)\.write_text") +PY_NAME = re.compile(r"[\w./\\-]+\.py\b") + + +def split(cmd): + """Shell text and heredoc bodies, kept apart: a body that mentions x.py is not a redirect.""" + shell, bodies, lines, i = [], [], cmd.split("\n"), 0 + while i < len(lines): + shell.append(lines[i]) + opener = HEREDOC.search(lines[i]) + i += 1 + if opener: + body = [] + while i < len(lines) and lines[i].strip() != opener.group(1): + body.append(lines[i]) + i += 1 + bodies.append("\n".join(body)) + i += 1 + return "\n".join(shell), bodies + + +def writes_py_by_heredoc(cmd): + shell, bodies = split(cmd) + if not bodies: + return False + if REDIRECT_PY.search(shell): + return True + return "python" in shell and any(PY_WRITE.search(body) for body in bodies) + + +def broken_py(cmd, cwd): + broken = [] + for name in sorted(set(PY_NAME.findall(cmd))): + path = Path(cwd, name) + if path.is_file() and time.time() - path.stat().st_mtime < 120: + try: + ast.parse(path.read_text(encoding="utf-8")) + except SyntaxError as err: + broken.append(f"{name} line {err.lineno}: {err.msg}") + return broken + + +def main(): + event = json.load(sys.stdin) + cmd = (event.get("tool_input") or {}).get("command") or "" + if event.get("hook_event_name") == "PreToolUse" and writes_py_by_heredoc(cmd): + reason = ("This heredoc writes a .py file. On Windows the Bash tool halves runs of backslashes " + "(except before a double quote) before bash reads the command, so escapes in string " + "literals can change. Use the Write tool.") + print(json.dumps({"hookSpecificOutput": { + "hookEventName": "PreToolUse", + "permissionDecision": "deny", + "permissionDecisionReason": reason}})) + elif event.get("hook_event_name") == "PostToolUse": + broken = broken_py(cmd, event.get("cwd") or ".") + if broken: + print(json.dumps({"hookSpecificOutput": { + "hookEventName": "PostToolUse", + "additionalContext": "A .py file no longer parses: " + "; ".join(broken)}})) + + +if __name__ == "__main__": + main() +``` + +Run through standard input the way the host runs it: + +| Event and command | Result | +| --- | --- | +| Pre: `cat > fix.py <<'EOF'` with a `\\n` in a string | Denied | +| Pre: `python - <<'EOF'` whose body calls `Path('app.py').write_text(...)` | Denied | +| Pre: `python - <<'EOF'` whose body only reads `app.py` | Allowed | +| Pre: `cat > notes.md <<'EOF'` whose text mentions `open('app.py', 'w')` | Allowed | +| Pre: `python fix.py` | Allowed | +| Post: `sed -i ... app.py` that left a string split by a real newline | Context added: `app.py line 1: unterminated string literal (detected at line 1)` | +| Post: `sed -i ... ok.py` that left valid code | Silent | + +Two mutants of the hook, one without heredoc detection and one without the redirect check, both fail these cases. Codex exposes the same events with a deny response \[7]; I have not tested whether its shell path has the bug. The deployed version, 253 lines, covers more write forms and is tested against 19 real commands that must be blocked and 18 that must pass. + +## The trigger was wrong on the first day + +The first version of the hook recognized three literal forms of writing a `.py` file. A cold review by a second agent found that none of the six forms in the incident transcript was among them: the hook would have passed the commands that motivated it. The fix went in 77 minutes after the first commit. Minutes before that commit it had also blocked a heredoc that wrote a Markdown index naming `.py` paths, so the `.py` file now has to be the target of the write. + +Two sibling rules built in the following days went the same way. One, for a `python -` heredoc with `