[{"data":1,"prerenderedAt":519},["ShallowReactive",2],{"navigation":3,"\u002Fwriting\u002Fteaching-claude-code-to-run-a-security-response":20},[4,8,12,16],{"title":5,"path":6,"stem":7},"About","\u002F","1.index",{"title":9,"path":10,"stem":11},"Work","\u002Fwork","2.work",{"title":13,"path":14,"stem":15},"Open Source","\u002Fopen-source","3.open-source",{"title":17,"path":18,"stem":19},"Writing","\u002Fwriting","4.writing",{"id":21,"title":22,"body":23,"cover":509,"date":510,"description":511,"extension":512,"meta":513,"navigation":514,"path":515,"seo":516,"stem":517,"__hash__":518},"writing\u002Fwriting\u002F1.teaching-claude-code-to-run-a-security-response.md","Teaching Claude Code to Run a Security Response",{"type":24,"value":25,"toc":498},"minimark",[26,39,48,53,74,77,81,95,102,105,157,160,170,177,181,184,207,210,214,217,240,243,247,250,265,268,271,275,278,281,308,411,414,417,421,424,427,456,460,466,484,488,491,494],[27,28,29,30,38],"p",{},"Security release day at an agency has a particular shape. The advisory lands, and the first question is simple: are we affected? Answering it isn't. At ",[31,32,37],"a",{"href":33,"rel":34},"https:\u002F\u002Fhappydog.digital",[35,36],"noopener","noreferrer","Happy Dog"," that question covers more than forty production applications, spread across a Kubernetes cluster, shared cPanel servers, a few single-purpose VPSes, PaaS platforms, managed WordPress hosting, static sites, and mobile apps. PHP is the biggest share, but there are plenty of Nuxt apps and a few Go and Python services alongside it, and each combination gets its updates through a different channel: a distro or vendor package repository, a Docker base image, a platform that manages the runtime for us, or a lockfile we bump ourselves.",[27,40,41,42,47],{},"This month I turned that response into a Claude Code ",[31,43,46],{"href":44,"rel":45},"https:\u002F\u002Fcode.claude.com\u002Fdocs\u002Fen\u002Fskills",[35,36],"skill",", and we've now run it twice: once to validate our rollout for the PHP remote code execution advisories, and today for a set of Symfony security releases. This post covers how it's built and what the two runs taught it.",[49,50,52],"h2",{"id":51},"where-this-started","Where this started",[27,54,55,56,61,62,67,68,73],{},"On October 21, PHP ",[31,57,60],{"href":58,"rel":59},"https:\u002F\u002Fwww.php.net\u002Farchive\u002F2026.php",[35,36],"published fixes"," for two critical bugs in multipart request handling (",[31,63,66],{"href":64,"rel":65},"https:\u002F\u002Fgithub.com\u002Fphp\u002Fphp-src\u002Fsecurity\u002Fadvisories\u002FGHSA-w8hw-w9fx-j79w",[35,36],"CVE-2026-109618"," and ",[31,69,72],{"href":70,"rel":71},"https:\u002F\u002Fgithub.com\u002Fphp\u002Fphp-src\u002Fsecurity\u002Fadvisories\u002FGHSA-6wmf-4rv5-p3rp",[35,36],"CVE-2026-109619",", both CVSS 9.2), after an embargo break the day before. We worked that response with Claude Code, and it went well, with one exception: at one point a session changed a live edge security policy without asking first. Nothing broke. That isn't the point. An agent that can read everything is enormously useful during an incident. An agent that decides on its own to change production during an incident is a second incident.",[27,75,76],{},"So once the dust settled, I wrote the process down as a skill, with that rule as its center.",[49,78,80],{"id":79},"the-shape-of-the-skill","The shape of the skill",[27,82,83,84,88,89,94],{},"A Claude Code skill is a directory with a ",[85,86,87],"code",{},"SKILL.md"," and whatever supporting files it needs. This one has an estate inventory, four briefs for ",[31,90,93],{"href":91,"rel":92},"https:\u002F\u002Fcode.claude.com\u002Fdocs\u002Fen\u002Fsub-agents",[35,36],"subagents",", a report template, and a guard hook. The main session acts as the orchestrator: it doesn't do the investigating, it hands that out, merges what comes back, holds every approval, and owns the commits.",[27,96,97,98,101],{},"Every response starts the same way. The orchestrator gets an ID and a workspace in my home directory, deliberately outside every repository, because a map of what's exposed where doesn't belong in version control. Then the orchestrator reads the advisory itself and writes a single ",[85,99,100],{},"advisory.md",": affected and fixed versions per branch, the vector and its preconditions, the upstream fix commits, vendor-recommended mitigations, and how confident it is in all of that. Four agents working from four slightly different readings of the advisory would produce four reports that don't merge.",[27,103,104],{},"Then three agents run in parallel, and a fourth runs after them:",[106,107,108,121],"table",{},[109,110,111],"thead",{},[112,113,114,118],"tr",{},[115,116,117],"th",{},"Agent",[115,119,120],{},"The question it answers",[122,123,124,133,141,149],"tbody",{},[112,125,126,130],{},[127,128,129],"td",{},"Exposure mapper",[127,131,132],{},"Does each target run the affected version, can an attacker reach the vulnerable path, and is anyone trying?",[112,134,135,138],{},[127,136,137],{},"Mitigation engineer",[127,139,140],{},"What can we change without a patched build that measurably closes the hole, and what does it break?",[112,142,143,146],{},[127,144,145],{},"Patch builder",[127,147,148],{},"Where does the fixed version come from for each target, and do we have a build that provably contains it?",[112,150,151,154],{},[127,152,153],{},"Rollout planner",[127,155,156],{},"What exact sequence takes all of that to production, how do we know each step worked, and how do we undo it?",[27,158,159],{},"The dispatch prompt for each one is short on purpose. It points the agent at the files instead of retyping them:",[161,162,168],"pre",{"className":163,"code":165,"language":166,"meta":167},[164],"language-text","Response \u003Cid>, workspace \u003Cdir>. Read ~\u002F.claude\u002Fskills\u002Fsecurity-response\u002Fbriefs\u002F_common.md,\nthen ~\u002F.claude\u002Fskills\u002Fsecurity-response\u002Fbriefs\u002F\u003Cbrief>.md, and follow both.\nScope: \u003Cthe targets and components in play, plus anything already known or ruled out>.\n","text","",[85,169,165],{"__ignoreMap":167},[27,171,172,173,176],{},"Everything ends up in a ",[85,174,175],{},"SECURITY_RESPONSE.md"," with one row per target and a go\u002Fno-go for each, plus one overall line. \"Go\" means the mitigation or patch is verified and the plan has a rollback. Platforms move independently, too: a shared server can be waiting on its vendor's packages while a container image is already rebuilt, so the report has to say who each target is waiting on.",[49,178,180],{"id":179},"the-inventory-is-a-baseline-not-the-truth","The inventory is a baseline, not the truth",[27,182,183],{},"The skill ships with an inventory of the estate: the big sites with dedicated hosting, the shared servers, how each CMS gets updated, which base branch each repository deploys from. It can't be the source of truth, though. Between new projects, hosting moves, and sites that get handed over or retired, what we're responsible for as a company changes faster than any document gets updated.",[27,185,186,187,194,195,198,199,206],{},"So the skill treats our GitHub organization as the canonical list. Every response sweeps the non-archived repositories by dependency, searching lockfiles, Dockerfiles, and CMS version files with ",[31,188,191],{"href":189,"rel":190},"https:\u002F\u002Fcli.github.com\u002Fmanual\u002Fgh_search_code",[35,36],[85,192,193],{},"gh search code",", rather than relying on what anyone remembers. When a hit turns up in a repository the inventory doesn't place, the agent works out the platform from the repository itself: a deploy workflow that copies files to a server, a ",[85,196,197],{},"vercel.json",", a Railway ",[31,200,203],{"href":201,"rel":202},"https:\u002F\u002Fdocs.railway.com\u002Finfrastructure-as-code",[35,36],[85,204,205],{},".railway\u002Frailway.ts",", an image-push step. Anything it can't resolve gets reported as \"hosting unknown\", never dropped.",[27,208,209],{},"That paid off on the first run. The PHP sweep turned up two containers the inventory hadn't caught up with yet, both of which needed the update, along with about a dozen repositories whose hosting wasn't written down anywhere the skill could find it.",[49,211,213],{"id":212},"every-claim-says-how-it-knows","Every claim says how it knows",[27,215,216],{},"The rule I'd carry into any multi-agent workflow: every claim is labeled with its basis.",[218,219,220,228,234],"ul",{},[221,222,223,227],"li",{},[224,225,226],"strong",{},"Verified:"," the command and its output are saved in the workspace.",[221,229,230,233],{},[224,231,232],{},"Read:"," it came from a file, a vendor page, or a read-only live query, and the source is cited.",[221,235,236,239],{},[224,237,238],{},"Assumed:"," and the claim says why.",[27,241,242],{},"The risk table carries those labels cell by cell, and a target whose exposure is entirely assumed isn't reported as safe. It's reported as unknown. A confident guess is worse than a marked gap, because the merge step can't tell it apart from a fact. When the orchestrator reads an agent's findings, the first thing it checks is anything marked verified without saved evidence.",[49,244,246],{"id":245},"prove-it-in-a-container","Prove it in a container",[27,248,249],{},"The mitigation engineer derives candidates from the upstream fix itself, not from whatever list is circulating online, and proves each one in a throwaway container with three tests: the vulnerable behavior reproduces on the unpatched version, the same input no longer reaches the bug with the mitigation on, and the traffic the business actually depends on, such as real file uploads, still works.",[27,251,252,253,256,257,260,261,264],{},"The report also has a \"Doesn't help\" section, and it's as valuable as the list of mitigations that do. For the PHP bugs, a few commonly suggested ideas didn't hold up. You can't turn off upload progress tracking with ",[85,254,255],{},"ini_set()"," from application code, because the setting can't be changed at runtime and the call returns ",[85,258,259],{},"false",". Lowering ",[85,262,263],{},"max_file_uploads"," doesn't bound the relevant input. Resource limits on the worker process make things worse, not better. Each of those has a test or a citation behind it.",[27,266,267],{},"When the agent can't build a safe reproduction, it says so. For the PHP bugs, it couldn't drive the affected build into the failure within the default limits, so the evidence is a proxy, showing that the hostile request shape reaches the vulnerable parser, and it's labeled as a proxy.",[27,269,270],{},"One practical lesson here cost me a frozen laptop. On the first run, two agents ran unbounded x86 containers at the same time, a PHP source build plus a test, under emulation on an ARM Mac. They took about 960% CPU and locked up my browser, terminal, and IDE. The shared brief now caps every container's CPU and memory, fixes compile parallelism at three jobs instead of the core count, allows one build at a time per agent, and prefers ARM images unless fidelity to the production build demands otherwise.",[49,272,274],{"id":273},"nothing-live-without-asking","Nothing live without asking",[27,276,277],{},"This is the part the skill exists for. Nothing changes staging or production, a live policy, a registry, a remote branch, or a server until I approve that specific step. Approval doesn't carry over from staging to production, from one target to the next, or from \"go ahead with the plan\" to an individual command. In practice it's a handoff: when the plan reaches a live step, the agent gives me the exact command, what it should change, and how to roll it back, and I review it and run it myself once I'm satisfied.",[27,279,280],{},"Subagents hold no approvals at all. They can read live state freely, including cluster state, logs, error tracking, and policy descriptions, but when a step needs a write, they write down the exact command for the rollout plan and stop.",[27,282,283,284,292,293,296,297,296,300,303,304,307],{},"Instructions are only half of that, so there's a backstop: a ",[31,285,288,291],{"href":286,"rel":287},"https:\u002F\u002Fcode.claude.com\u002Fdocs\u002Fen\u002Fhooks",[35,36],[85,289,290],{},"PreToolUse"," hook"," that inspects every shell command and certain MCP tool calls before they run. If one looks like it could change live infrastructure, such as ",[85,294,295],{},"kubectl apply",", ",[85,298,299],{},"gcloud … update",[85,301,302],{},"ssh",", a ",[85,305,306],{},"git push",", a registry push, a Railway deploy, or an HTTP write to anything that isn't localhost, the hook returns an \"ask\" decision. That forces a permission prompt whatever mode the session is in, and the hook logs the attempt.",[161,309,313],{"className":310,"code":311,"language":312,"meta":167,"style":167},"language-json shiki shiki-themes github-light github-dark","{\n    \"matcher\": \"Bash|mcp__railway__.*|mcp__plugin_sentry_sentry__update_.*\",\n    \"hooks\": [\n        {\n            \"type\": \"command\",\n            \"command\": \"python3 -I ~\u002F.claude\u002Fskills\u002Fsecurity-response\u002Fbin\u002Flive-guard.py\",\n            \"timeout\": 10\n        }\n    ]\n}\n","json",[85,314,315,324,341,350,356,369,382,393,399,405],{"__ignoreMap":167},[316,317,320],"span",{"class":318,"line":319},"line",1,[316,321,323],{"class":322},"sVt8B","{\n",[316,325,327,331,334,338],{"class":318,"line":326},2,[316,328,330],{"class":329},"sj4cs","    \"matcher\"",[316,332,333],{"class":322},": ",[316,335,337],{"class":336},"sZZnC","\"Bash|mcp__railway__.*|mcp__plugin_sentry_sentry__update_.*\"",[316,339,340],{"class":322},",\n",[316,342,344,347],{"class":318,"line":343},3,[316,345,346],{"class":329},"    \"hooks\"",[316,348,349],{"class":322},": [\n",[316,351,353],{"class":318,"line":352},4,[316,354,355],{"class":322},"        {\n",[316,357,359,362,364,367],{"class":318,"line":358},5,[316,360,361],{"class":329},"            \"type\"",[316,363,333],{"class":322},[316,365,366],{"class":336},"\"command\"",[316,368,340],{"class":322},[316,370,372,375,377,380],{"class":318,"line":371},6,[316,373,374],{"class":329},"            \"command\"",[316,376,333],{"class":322},[316,378,379],{"class":336},"\"python3 -I ~\u002F.claude\u002Fskills\u002Fsecurity-response\u002Fbin\u002Flive-guard.py\"",[316,381,340],{"class":322},[316,383,385,388,390],{"class":318,"line":384},7,[316,386,387],{"class":329},"            \"timeout\"",[316,389,333],{"class":322},[316,391,392],{"class":329},"10\n",[316,394,396],{"class":318,"line":395},8,[316,397,398],{"class":322},"        }\n",[316,400,402],{"class":318,"line":401},9,[316,403,404],{"class":322},"    ]\n",[316,406,408],{"class":318,"line":407},10,[316,409,410],{"class":322},"}\n",[27,412,413],{},"It never denies anything, and its patterns are deliberately biased toward asking. Reading the log, a fair share of what it catches is harmless, such as a local commit whose message happens to mention an infrastructure script by name. A false positive costs one prompt. A false negative costs a production change nobody approved.",[27,415,416],{},"The hook started out registered in the skill's own frontmatter. A test showed it never fired inside subagents, which is where it's needed most, so it moved to my global settings. I confirmed the fix by having a subagent run a harmless command that matches one of its patterns and watching for the prompt. Even so, the skill treats the hook as a backstop: the orchestrator stops before every live step whether the hook fires or not.",[49,418,420],{"id":419},"servers-go-through-me","Servers go through me",[27,422,423],{},"Some targets can't be reached from my machine, or shouldn't be reached by an agent. For those, agents write read-only collection scripts: no writes, no restarts, safe to run as root, output to stdout. The orchestrator bundles each server's scripts into one file, so it's one run per server, and gives me a single command that pipes the script over SSH and saves the output as evidence.",[27,425,426],{},"Writing scripts that run correctly on someone else's server is a skill of its own, and the brief has collected a list of mistakes that each cost a re-run:",[218,428,429,436,442,449],{},[221,430,431,432,435],{},"Older CentOS servers have no ",[85,433,434],{},"python3",", so the scripts use Perl or shell.",[221,437,438,441],{},[85,439,440],{},"dpkg-query -W"," also lists removed packages that left their configuration behind, and the orchestrator once misread one as installed. The scripts now always print the package status.",[221,443,444,445,448],{},"An unbounded ",[85,446,447],{},"grep"," across every docroot on a shared server ran for over twenty minutes before we killed it.",[221,450,451,452,455],{},"A suspended site, or one whose DNS has moved, can still answer on the server's IP, so reachability is checked with ",[85,453,454],{},"curl --resolve"," rather than DNS.",[49,457,459],{"id":458},"two-runs-so-far","Two runs so far",[27,461,462,465],{},[224,463,464],{},"The PHP replay."," We had already responded to the PHP advisories a week earlier, so the first run was a validation: replay the response against today's estate, and see whether the skill reaches the same conclusions and anything we missed. It confirmed the fixed builds and restarted workers on every server it could check, confirmed the mitigations were in place where we expected them, and picked up the two containers and the unmapped repositories. It also exposed a gap in the orchestration: the exposure mapper reported new targets, but the orchestrator never passed them to the mitigation and patch agents, so it ended up doing their work itself. Forwarding new targets to the other agents is now a required step.",[27,467,468,471,472,477,478,483],{},[224,469,470],{},"The Symfony releases."," Today Symfony shipped ",[31,473,476],{"href":474,"rel":475},"https:\u002F\u002Fsymfony.com\u002Fblog\u002Fcategory\u002Freleases",[35,36],"security releases"," across all four maintained branches. Every deployed application we host that uses Symfony was behind. Seven were updated and released the same day, each through its normal path: development branch, green CI, then production, with every merge and push approved one at a time. One application is pinned to a framework version that can't take the upstream release, so it got the fix backported as a ",[31,479,482],{"href":480,"rel":481},"https:\u002F\u002Fgithub.com\u002Fcweagans\u002Fcomposer-patches",[35,36],"Composer patch",", and its CI needed two fixes of its own because its base image has reached end of life. An application scheduled for decommission got its remaining advisories ignored explicitly, each one naming its package and fixed version so the decision is visible later. And a CMS that bundles an unmaintained Symfony version was recorded as something only a CMS release can fix.",[49,485,487],{"id":486},"it-gets-better-every-time","It gets better every time",[27,489,490],{},"The last step of every response is a close-out that updates the skill itself. The inventory picks up whatever the run proved stale or newly mapped. The orchestration and the briefs pick up the lessons, and each lesson is written with its date and the reason behind it: \"on 2026-10-27, this step was skipped and the orchestrator had to do the patch builder's work itself\" is far more persuasive to a future session than a bare \"always do X.\" When a project's own documentation should learn something, the skill proposes the change instead of writing it into the repository quietly.",[27,492,493],{},"If you're building something similar, that's my advice. Let the agents do the reading, because they're tireless at sweeping repositories, lockfiles, logs, and vendor changelogs. Make them say how they know what they claim to know. Prove the fix in a container before believing it. And keep every write to a live system behind a human decision, with a hook behind the instructions in case the instructions aren't enough.",[495,496,497],"style",{},"html pre.shiki code .sVt8B, html code.shiki .sVt8B{--shiki-default:#24292E;--shiki-dark:#E1E4E8}html pre.shiki code .sj4cs, html code.shiki .sj4cs{--shiki-default:#005CC5;--shiki-dark:#79B8FF}html pre.shiki code .sZZnC, html code.shiki .sZZnC{--shiki-default:#032F62;--shiki-dark:#9ECBFF}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}",{"title":167,"searchDepth":326,"depth":326,"links":499},[500,501,502,503,504,505,506,507,508],{"id":51,"depth":326,"text":52},{"id":79,"depth":326,"text":80},{"id":179,"depth":326,"text":180},{"id":212,"depth":326,"text":213},{"id":245,"depth":326,"text":246},{"id":273,"depth":326,"text":274},{"id":419,"depth":326,"text":420},{"id":458,"depth":326,"text":459},{"id":486,"depth":326,"text":487},null,"2026-10-28T22:00:00.000Z","After a critical PHP advisory and a round of Symfony security releases, here's how we turned \"check everything we run\" into a Claude Code skill — four investigating agents, mitigations proven in Docker, and an approval gate in front of every live change.","md",{},true,"\u002Fwriting\u002Fteaching-claude-code-to-run-a-security-response",{"title":22,"description":511},"writing\u002F1.teaching-claude-code-to-run-a-security-response","8LRLp5_qwbskagqnj1ayALZjdzNT_XIwUgdMyoUjJ20",1793230978693]