Elusive Thoughts // AppSec
The controls your developers never see
We rebuilt the SDLC for agent-assisted engineering. The hardest requirement was not coverage. It was invisibility.
Every application security programme I have worked on failed at the same place, and it was never detection. It was adoption.
You buy the scanner. It finds real bugs. Then you spend the next eighteen months asking engineers to change how they work, and some of them do, and most of them find the path of least resistance around you. The control decays into a dashboard that gets opened during audit season. You write a policy to compensate. Nobody reads the policy either.
I have watched that cycle three times now. The tooling was never the problem. The delivery model was, because it assumed the right place for a security control is in front of a human being who has other priorities.
Two things changed at once
The first is arithmetic. One engineer with agents ships roughly what three used to. Code volume went up, review capacity did not move, and the ratio of lines-written to lines-actually-read collapsed. Every assumption your programme makes about human review scaling with output is now wrong.
The second is harder. The set of people writing code that reaches production stopped being "developers". An analyst who can describe what they want now ships an internal tool. Someone in ops automates a runbook into a service. These are not junior engineers who will learn your conventions over time. They will never attend your secure coding training, because they do not think of themselves as the audience for it, and they are right not to.
Agents removed the last excuse for a training-based model. An agent cannot be socialised, reminded in a retro, or made to care. It responds to exactly one thing, which is a gate in its execution path. So do the analysts, incidentally, and so did the engineers all along.
We stopped asking people to participate in security and started intercepting the artefacts. The design constraint became: if anyone notices the control, we have implemented it wrong.
Layer one: the pipeline
Aikido across the scanning surface
SCA, IaC and SAST consolidated into one platform, which matters less for coverage than for the fact that findings arrive through one path with one triage model. Fifteen tools produce fifteen queues and fifteen sets of false positives, and the aggregate lesson engineers learn is that every alert is probably noise.
The part carrying the design goal is autofix. Aikido opens the remediation PR itself. The interaction with the security control is reviewing a small diff that already passes tests, which is something engineers do hundreds of times a week and have no feelings about. Compare a Jira ticket reading "upgrade transitive dependency, CVSS 8.1" landing in a sprint that was already full.
Same fix. Completely different adoption curve. The difference is entirely whether the control arrived as work or as a diff.
Safe Chain, and why the package manager is the real endpoint
Aikido Safe Chain wraps the package managers with shell aliases and routes installs through a local proxy that checks each package and its dependencies against Aikido Intel before anything lands on disk. It covers npm, npx, yarn, pnpm, pnpx, bun, bunx, rush and rushx on the JavaScript side, and pip, pip3, uv, uvx, poetry, pipx and pdm on the Python side.
It also enforces a 48-hour minimum package age by default, which is the single cheapest control in this entire architecture.
shell alias wrap
npm install and either gets a package or gets an error. Same for the analyst, the container build, and the VM the agent executes in.Supply chain compromises are loud and fast. Shai-Hulud propagated in hours across 160-plus npm packages and was publicly identified well inside two days. The window between "malicious version is live" and "the ecosystem knows" is short, and waiting two days costs almost nothing in nearly every real case.
The complaints are real but rare, roughly [insert: how often you actually field an exception request], and there is an exception path with a named approver.
GitGuardian on secrets, twice
Pre-commit and again in CI, and the duplication is deliberate. The pre-commit hook exists for developer experience, because catching a credential before it enters history saves a rotation. It is advisory. Anyone can pass --no-verify and eventually someone does.
The CI check is the actual control. It cannot be skipped and it blocks the merge.
Treating a pre-commit hook as a security boundary is a common mistake worth being precise about. It runs on a machine you do not control, in a shell you do not control, at the discretion of a person under deadline pressure. It is a convenience. The gate is in CI or it does not exist.
GitGuardian's 2026 figures put AI-service secrets up 81% year on year, with AI-assisted commits leaking at roughly double the baseline rate. The mechanism is not carelessness. AI-assisted commits are larger and land faster while review capacity stayed flat. The gate has to be mechanical because the volume outran the humans.
Signed PRs, and the key rotation nobody plans for
Commit and PR signing enforced at the gate. This was the least popular change we made and I would do it again first.
Without signing, "this change came from a member of the team" is an assumption resting on the security of a token. With signing it is a verifiable property. In an environment where agents open pull requests using credentials living in CI configuration, that distinction stops being philosophical.
Shai-Hulud is the argument. That worm published to npm with valid provenance attestations, because the compromised build genuinely did run in the real pipeline. Provenance answered the question it was designed to answer. Identity at the point of authorship is a different question and you want both.
The part people skip is key lifecycle, so here is ours, deliberately boring:
- Issuance. Ed25519 SSH signing keys, generated on the device, private key never leaves it. Registered against the identity in the forge at onboarding.
- Rotation on a fixed clock. 90 days, no exceptions, automated reminder at 75. Overlap window of 7 days where both old and new keys verify, so nothing breaks mid-rotation.
- Rotation on event. Device loss, role change, or any suspicion at all. One command revokes and reissues.
- Agent keys are separate and shorter-lived. An agent that opens PRs gets its own key with a 30-day life, scoped to its own identity, so the audit trail distinguishes "Jerry merged this" from "the release agent merged this". Those are different events and should never share a signature.
- Verification is the gate, not the honour system. Unsigned or unverifiable means the PR does not merge. No override, because an override that exists gets used at 5pm on a Friday.
Rollout was the worst week of the project. The signing rollout took three weeks and ith good testing nothing, broke (including AI Agents)
Layer two: the AI layer
This is the part with no established playbook, so expect more opinion and less certainty from here.
Codebase context as a security control
We maintain a structured context layer describing our architecture, conventions, approved libraries and the patterns we do not use. Agents load it before writing anything.
Most people file this under code quality. I would argue it is a high-leverage security control.
Think about where AI-generated vulnerabilities actually come from. Very few are exotic. They are the model reaching for a generic pattern because it has no idea what your codebase does. Raw SQL string building in a repository that has used parameterised queries exclusively since 2019. A hand-rolled JWT check sitting next to your existing middleware. A permissive CORS default lifted from a tutorial.
The model is not being unsafe. It is being generic, in a codebase where generic is unsafe. Context closes most of that gap before a scanner gets a chance, and shifting left does not go further left than "the vulnerable line is never written".
Willow as the agent gateway
Willow sits between agents and the tools they reach, and every agent action passes through it. It evaluates identity, which agent is asking. Connection, whether that agent is authorised for this tool at all. Action scope, what it may do inside that tool. And risk, whether this specific call is safe. The outcomes are allow, block, redact, or escalate to a human.
It also surfaces shadow AI, meaning the unmanaged agents and unapproved MCP servers people stand up without telling anyone, which in my experience is a larger number than anybody expects.
On top of the platform we run a hardening layer of MITRE-formulated regex for prompt injection detection at the gateway. This is our addition rather than a product default, and it earns its place because the injection surface in a real engineering workflow is enormous. Agents read Jira tickets, GitHub issues, PR comments, log output and documentation. Ticket bodies are written by customers. Issue text is written by strangers. All of it arrives in the same token stream as your instructions with no reliable boundary between command and data.
→ scope → risk
Opal for just-in-time agent identity
Willow answers "may this agent do this". Opal answers "should this agent still have access at all, right now".
The failure mode it addresses is the one every non-human identity programme hits. An agent is provisioned for a task, granted the access that task needs, and then simply keeps it. Six months later there are forty agents holding standing access to production, most of them orphaned, none of them reviewed, all of them a live credential in someone's environment variable.
Opal grants access just in time, scoped and expiring, across human, non-human and agentic identities in one governance model. The agent requests, the grant is time-boxed, the grant dies. What this buys you concretely is that the answer to "what can this compromised agent reach" changes from "everything it was ever given" to "whatever it holds in this window", which is usually nothing.
Standing access is the thing that turns one compromised agent into an incident. JIT is how you stop having standing access without stopping the work.
A dedicated skills repository
Company skills live in one repo, reviewed like code, versioned like code. Agents install from ours and nowhere else, and the 48-hour hold applies to skills exactly as it applies to packages.
The move that has paid off most: OWASP guidance is encoded as a skill, not documented as a page.
Everyone has a wiki with the Top Ten on it. Nobody reads it, including the humans it was written for. Encoded as a skill the agent loads while writing code, the guidance applies at the moment of authorship rather than being recalled during a review that may not happen. Documented guidance depends on somebody remembering. Encoded guidance does not.
It also happens to be the only mechanism I have found that gets secure coding standards in front of the analyst who has never heard of OWASP and never will.
Claude hooks, hardened
Hooks were built for convenience. We use them as enforcement points, and they are the last line before a tool call becomes an action on a real system.
from content, not user?
drop · terraform destroy
ephemeral
Three guards, in order of how often they fire. Injection checking on instructions that arrived via content rather than the operator. Destructive command matching, which is unglamorous pattern work against rm -rf, force pushes, DROP, terraform destroy and the rest of the list you write once and then extend after every near miss. And shell sandboxing, so that when something does slip through, it executes with no network, a scoped filesystem and no persistence.
The config, and the regex
Below is the actual wiring rather than a description of it. Everything maps to a MITRE technique ID, because "we block dangerous commands" is not a control anyone can audit, and "we block AML.T0054 at PostToolUse" is.
Four hook events do the work. PreToolUse on Bash for destructive and exfiltration patterns. PreToolUse on Write|Edit to catch tampering with the agent's own configuration. UserPromptSubmit for direct injection. And PostToolUse on anything that pulls external content, which is where indirect injection actually arrives.
// .claude/settings.json — commit this, every clone inherits it
{
"hooks": {
"PreToolUse": [
{ "matcher": "Bash",
"hooks": [{ "type": "command",
"command": "python3 .claude/hooks/guard.py bash",
"timeout": 15 }] },
{ "matcher": "Write|Edit|NotebookEdit",
"hooks": [{ "type": "command",
"command": "python3 .claude/hooks/guard.py write",
"timeout": 15 }] }
],
"UserPromptSubmit": [
{ "hooks": [{ "type": "command",
"command": "python3 .claude/hooks/guard.py prompt",
"timeout": 10 }] }
],
"PostToolUse": [
{ "matcher": "WebFetch|WebSearch|Read|Glob|Grep|mcp__.*",
"hooks": [{ "type": "command",
"command": "python3 .claude/hooks/guard.py content",
"timeout": 15 }] }
]
}
}
The rules live in a separate JSON file so they can be reviewed and versioned without touching the wiring. A representative slice:
| Technique | Pattern | Action |
|---|---|---|
| AML.T0051.000 Direct injection |
(?:ignore|disregard|forget|override)\s+(?:all\s+|any\s+|your\s+|the\s+)?(?:previous|prior|earlier|above|preceding|system)\s+(?:instruction|prompt|rule|direction|message|context) | block |
| AML.T0051 System prompt extraction |
(?:reveal|print|show|output|repeat|display|dump|echo)\s+(?:me\s+)?(?:your|the|all)\s+(?:system\s+prompt|initial\s+instruction|hidden\s+prompt|configuration) | block |
| AML.T0054 Indirect injection |
(?:AI\s+(?:assistant|agent|model)|claude|chatgpt|copilot|gpt-?\d)[,:\s]+(?:please\s+)?(?:ignore|disregard|instead|you\s+must|your\s+new\s+task|do\s+not\s+tell) | block |
| AML.T0054 Confidentiality coercion |
(?:do\s+not|don'?t|never)\s+(?:tell|inform|mention\s+to|alert|notify)\s+(?:the\s+)?(?:user|operator|human|developer|anyone) | block |
| AML.T0080 Agent config tampering |
(?:>>?|tee|sed\s+-i|cat\s*>)\s*[^\n|;]*(?:CLAUDE\.md|AGENTS\.md|\.claude/settings(?:\.local)?\.json|\.claude/hooks|\.mcp\.json) | block |
| AML.T0105 Container escape |
\b(?:docker\s+run[^\n]*(?:--privileged|--pid=host|-v\s*/:/|/var/run/docker\.sock)|nsenter\s+-t\s*1|chroot\s+/host) | block |
| T1552.001 Credential file read |
(?:cat|less|head|tail|cp|scp|base64|xxd)\s+[^\n|;]*(?:\.env|\.aws/credentials|\.ssh/id_(?:rsa|ed25519)(?!\.pub)|\.netrc|\.npmrc|kube/config) | block |
| T1485 Destructive git |
\bgit\s+(?:push\s+[^\n]*(?:--force(?!-with-lease)|-f\b)|reset\s+--hard\s+(?:origin/)?(?:main|master|prod)|branch\s+-D\s+(?:main|master)) | block |
| T1059 Pipe to shell |
(?:curl|wget)\s+[^\n|]*\|\s*(?:sudo\s+)?(?:ba|z|k|d)?sh\b | block |
| AML.T0024 Markdown image exfil |
!\[[^\]]*\]\(\s*https?://(?!(?:localhost|127\.0\.0\.1))[^)\s]*(?:\?|&)[^)\s]*=(?:\{|\$|%7B) | block |
Two details worth stealing. --force(?!-with-lease) permits the safe variant, because a rule that blocks the correct behaviour alongside the dangerous one teaches people to disable the rule. And id_(?:rsa|ed25519)(?!\.pub) ignores public keys, which are meant to be read.
The blocking contract matters more than the patterns. The hook writes JSON to stdout and exits 2:
{
"continue": false,
"stopReason": "ATLAS guard: EXFIL-01 (T1552.001)",
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "deny",
"permissionDecisionReason": "..."
}
}
Exit 2 is the control, not the JSON. Exit 0 means the JSON is advisory and can be overridden. Exit 1 is a non-blocking error, so a guard that crashes with exit 1 fails open and you will not notice. Ours fails closed on a broken ruleset, which is deliberate. A control that disappears when its config breaks was never a control.
Regex on shell strings is defence in depth and nothing more. It is trivially defeated by base64, variable indirection, or an attacker who reads this post. It exists to stop accidents and unsophisticated injection payloads, which is the overwhelming majority of what actually happens, while the sandbox handles the rest. If your only agent control is pattern matching on commands, you do not have a security model. You have a speed bump with good documentation.
The other caveat: the exact shape of tool_input is not fully documented per tool. Rather than guessing at tool_input.command and silently scanning nothing if a field gets renamed, the guard walks the whole object and scans every string it finds.
This is the layer where "agents are service accounts" stops being a slogan. The agent has an identity from Opal, a scope from Willow, an audit trail of tool calls rather than prompts, and a blast radius somebody has actually written down.
What it cost
| Control | Visible to author? | Real friction |
|---|---|---|
| Aikido SCA / IaC / SAST | Only on a finding | Low |
| Aikido autofix | As a PR to review | Near zero |
| Safe Chain proxy | No | Near zero |
| 48h minimum age | Only when blocked | Occasional real complaint |
| GitGuardian pre-commit | Only when it fires | Seconds |
| GitGuardian CI gate | Only on failure | Blocks merge, correctly |
| PR gate | One status check | Near zero |
| Signed PRs + rotation | Setup, then 90-day rotate | High at rollout, low after |
| Codebase context | No | Maintenance burden on us |
| Willow + MITRE regex | No, unless escalated | Latency, small |
| Opal JIT | Request flow for agents | Low, once wired |
| Skills repo | Yes, they contribute | Review load on AppSec |
| Claude hooks | Only on deny | Near zero |
Two of thirteen are meaningfully visible, and one of those is visible because we want people contributing skills.
The honest costs. This took substantially longer than a tool rollout, because invisible controls mean you own the integration work rather than pushing it onto teams. The context layer needs maintenance or it rots into lies, which is worse than having none. The skills repo puts permanent review load on AppSec. And the 48-hour hold produces a genuine argument every so often, usually when the fix somebody needs shipped this morning.
The thing I did not expect
We built this for developer experience. We got agent governance as a side effect, and I did not see that coming.
The reason is obvious in retrospect. A control designed to work without human cooperation also works without agent cooperation, because it never depended on cooperation at all. Every mechanism here is mechanical enforcement in the execution path. Agents are subject to it for the same reason developers are, and the same reason the analyst shipping her first internal tool is, which is that none of them were asked.
That last group is the one I would think hardest about. Your programme was built for people who identify as engineers and who can be reached through engineering culture. That population is no longer the whole population writing code that reaches production, and it is shrinking as a proportion of it. Training does not reach the rest. Culture does not reach the rest. A gate in the path reaches everyone, because it does not care who you are.
Meanwhile the industry response to AI risk has been governance frameworks, acceptable use policies and training modules. All of which are instructions addressed to a reader.
If your AI security strategy is a policy document, your agents have not read it. They are not going to. They are going to call the tool, and the only question that matters is whether something in the path checks the call.
Put the control in the path. Nobody has to agree to it.
Running in production at [Freetrade / Diixtra — say which, or keep it generic]. Corrections welcome, particularly on the Willow and Opal design, both of which are newer than I would like.