30/08/2026

The AI pentest that survives day three: a Temporal backbone for agentic offensive security

The AI pentest that survives day three: a Temporal backbone for agentic offensive security

The AI pentest that survives day three

Everyone is shipping autonomous pentesters. Almost nobody is shipping one that survives a real engagement. This is the backbone that does: Temporal underneath, Kali and Burp doing the work, a human holding the trigger.

August 2026  ·  offensive AI  ·  durable execution  ·  ~18 min read

I have an agentic pentest harness. It is a Kali box exposing 26 tools over SSH through an MCP server, an LLM planning the next move, and enough glue to let it run recon, enumeration and a first pass at exploitation without me touching the keyboard. On a lab target it is genuinely impressive. It chains subfinder into httpx into nmap, reads the output, picks a plausible next step, and keeps going.

Then I pointed it at a real engagement. It died on day three.

Not because the model was stupid. Because the LLM API rate-limited me halfway through a scan and the loop threw an exception and unwound. Because a forty minute nmap run against a large range hung and I had no way to tell whether it was alive or wedged. Because the Kali VPS rebooted overnight and took every scrap of in-memory state with it. Because at one point the agent stopped running commands and started imagining their output, confidently reporting findings for hosts it had never touched. That last one has a name now. The EnIGMA researchers called it soliloquising, and once you have watched an agent do it you stop trusting anything it says without a receipt.

The intelligence was fine. The execution layer under it was cardboard. My harness was a clever brain bolted onto a fragile nervous system, and a real engagement is exactly the environment that finds every fragile thing you shipped.

This post is about the nervous system. It is about wrapping the whole workflow in durable execution with Temporal, so the engagement retries when the model flakes, resumes when the box dies, waits days for a human to approve an exploit, and writes an audit trail you could hand to a client or a regulator without flinching. Kali and Burp still do the actual work. Temporal is what makes the work trustworthy enough to put your name on.

Code is Python throughout, because that is what the harness is already written in. Let me start with why the obvious build is a trap.

The build everyone reaches for first

Strip away the framework of the week and every agentic pentester is the same loop. Ask the model what to do next. Run it. Feed the result back. Repeat until it thinks it is finished.

# the harness, roughly. runs great in a demo.
context = []
while not done:
    step   = llm.plan(context)          # what should we do next?
    result = ssh_kali(step.command)      # run it on the Kali box
    context.append((step, result))       # remember it... in RAM
    done   = llm.is_finished(context)

Five lines. Every failure from the intro is already in here.

context is a list in memory, so when the VPS reboots the engagement is gone. ssh_kali raises on a dropped connection and the whole loop unwinds. llm.plan returns a 429 under load and the loop unwinds again. There is no point in those five lines where a human approves anything before it fires, and there is no record of what happened beyond whatever you thought to print at the time.

So you start bolting things on. A try/except around the SSH call. A retry decorator on the model call. Postgres for the context so it survives a reboot. An approvals table with a poller. A logging pipeline so you have some idea what ran. Six months later you have written a worse, undocumented, untested version of a durable execution engine, and you are maintaining it alone.

Durable execution is the part of the agent nobody demos and everybody eventually rebuilds badly.

Temporal is that engine, done properly. Before the architecture, here is the whole argument in one table.

When this happensAgent as a scriptDurable workflow
The box crashes mid-engagementstate lost, start from zeroresumes at the exact step
LLM returns 429 or 5xxexception unwinds the runactivity retries with backoff
A 40-minute nmap runhung or working? no ideaheartbeat proves liveness
Exploit needs human sign-offan input() prompt, or nothingsignal, waits for days
Client asks what you ranwhatever you loggedfull event history
Target drifts out of scopea hope and a commentnon-retryable guard refuses
200 hosts to covergather() and praychild workflow per host, bounded

Same model, same tools. The right-hand column is what a backbone buys you.

The architecture

Here is the whole thing on one page. Read it top to bottom. A trigger starts a workflow, the workflow orchestrates activities, the activities drive Kali and Burp through a governance layer, and everything that happens lands in an event history that doubles as your evidence and your metrics.

Figure 1  /  end-to-end architecture
Orchestration · Temporal
Trigger
code change · CI · schedule
PentestWorkflow
holds state · zero I/O
🔒 Human gate
approval signal
Activities · all I/O lives here
llm_plan
next move
recon / enum
Kali tools
burp_scan
web DAST
exploit
after the gate
report
findings + KPIs
Governance · every tool call
Tool gateway
identity · scope · risk → allow / block / redact / escalate
Opal
just-in-time access
hardened hooks
last guard before action
Execution
Kali box
MCP harness · 26 tools · SSH · nmap, nuclei, sqlmap, amass
Burp Suite DAST
GraphQL · create_schedule_item
Scoped targets · rules of engagement enforced in code
Durability · the payoff
Event history
every call + return, replayable
Audit trail
chain of custody
SDLC KPIs
coverage · MTTR · FP rate

Figure 1. The workflow never touches the network. Everything with a blast radius is an activity, behind governance, against a scoped target, recorded in history.

The single most important rule is the boring one at the top. The workflow orchestrates and holds state. It never does I/O. Every call to the model, every tool run, every Burp scan, every line written to a report is an activity. That split is not a style preference. It is the thing that makes the workflow replayable, and replay is where all the durability comes from. I will come back to it.

Notice the band in the middle. The agent driving this pentest is still an agent, and it gets governed like every other agent in the estate. Tool calls route through a gateway that checks four things in order: which agent this is, whether it is allowed to reach this tool at all, what it may do inside it, and whether this specific call is safe. Its access is just-in-time, granted for the engagement and pulled afterwards, so a compromised pentest agent can reach whatever it holds in that window and nothing else. Offensive tooling is the last thing in your estate you want holding standing production credentials.

Scope follows the diff

The trigger box in Figure 1 says code change on purpose. The useful version of this system does not wait for a quarterly booking. It starts when a pull request does, and the scope of the test is derived from what actually changed.

A diff that adds one endpoint does not need an estate sweep. It needs that endpoint hammered. A change across the API surface needs the whole web app API scanned. A Terraform change needs the cloud infra looked at, not the login form. Same engine, different scope, decided automatically from the diff.

Figure 2  /  scope follows the diff
PR / commit
changed paths
diff classifier
derive scope + task queue
targeted
one new endpoint → Burp API scan + authz/BOLA probes
api-wide
whole web app API → DAST over the OpenAPI spec + nuclei
infra
cloud / Terraform → recon + misconfig & exposure sweep
full
nightly / weekly → child workflow per host

Figure 2. One dispatcher, four scopes. The blast radius of the test matches the blast radius of the change.

What changedDerived scopeWhat the workflow runs
One new route, e.g. POST /v2/payoutsthat route + its authBurp API scan of the route, BOLA / authz probes, an llm_plan focused on the new surface
Broad API change, OpenAPI spec bumpedwhole web app APIBurp DAST driven from the spec, nuclei templates, parameter fuzzing
Terraform / IaC changecloud infra in the blast radiusrecon, exposed-service sweep, nuclei misconfig checks against the changed footprint
Nightly schedulefull agreed scopeeverything, fanned out one child workflow per host

The dispatcher is thin. A CI step or a webhook inspects the changed paths, builds a scope, and starts the right workflow. The important detail is the workflow ID.

from temporalio.client import Client
from dataclasses import dataclass

@dataclass
class ScopeSpec:
    kind: str               # "targeted" | "api_wide" | "infra" | "full"
    targets: list[str]      # hosts, base URLs, or cloud accounts in scope
    openapi: str | None = None
    engagement_id: str = ""

def classify(changed: list[str]) -> ScopeSpec:
    if any(p.endswith(".tf") or p.startswith("infra/") for p in changed):
        return ScopeSpec("infra", targets=cloud_scope())
    if "openapi.yaml" in changed:
        return ScopeSpec("api_wide", targets=[API_BASE], openapi="openapi.yaml")
    routes = [p for p in changed if p.startswith("api/routes/")]
    if routes:
        return ScopeSpec("targeted", targets=routes_to_urls(routes))
    return ScopeSpec("full", targets=full_scope())

async def dispatch(pr: str, sha: str, changed: list[str]) -> None:
    client = await Client.connect("temporal:7233", namespace="pentest")
    scope = classify(changed)
    scope.engagement_id = f"{pr}-{sha[:12]}"
    await client.start_workflow(
        PentestWorkflow.run,
        scope,
        id=f"pentest-{scope.engagement_id}",   # one run per PR+sha, re-triggers dedupe
        task_queue=f"pentest-{scope.kind}",       # infra work runs off its own worker pool
    )

The workflow ID is keyed to the PR and commit, so pushing the same commit twice does not start two engagements. The task queue is keyed to the scope kind, so a cloud scan never lands on the workers that run web tests.

Because it starts in CI, it gates in CI. The workflow returns a verdict, the PR check goes red on a confirmed high, and every run stamps the same event history you will later mine for coverage-per-change and time-to-first-finding. This sits right beside the SAST and SCA already running on the diff. Same trigger, deeper test.

Temporal in five minutes, for people who break things

Three concepts and one rule.

Workflows and activities

A workflow is your orchestration logic. It decides what happens next. An activity is anything that touches the outside world: a model call, a shell command on Kali, a Burp scan, a write to disk. Activities are allowed to be slow, flaky and failure-prone, and Temporal wraps every one of them in configurable timeouts and retries.

The rule from Figure 1, restated because it is the whole game: orchestration goes in the workflow, I/O goes in activities. Break that and everything below stops working.

Determinism and replay

Temporal does not snapshot your process memory. It records an event history: every activity the workflow scheduled and every result that came back. When a worker dies, a fresh one replays that history through your workflow code, rebuilds the exact in-memory state, and carries on from the last completed step. Nothing re-runs that already finished.

That is why the workflow has to be deterministic. A raw datetime.now(), a bare random(), a direct HTTP call, any of them make the replay diverge from the recorded history and Temporal will refuse it. You get deterministic replacements for the safe things, workflow.now() and workflow.random(), and activities for everything else. It feels like a constraint for about a day, then it feels like the reason the thing never loses state.

The one thing to remember

The event history that makes the workflow crash-proof is the same artefact that makes it auditable. You are not building durability and evidence separately. They are the same log.

The engagement as a workflow

Model the engagement as a state machine. Each phase is durable, so the workflow always knows where it is, even after the third worker restart of the week.

Figure 3  /  engagement state machine
Scoping
RoE, targets
Recon
activity, retried
Enumerate
heartbeat
Analysis
llm + dedupe
🔒 Gate
signal / 24h timer
Exploit
approved only
Post-ex
proof
Report
findings + KPIs

Figure 3. Every box is a persisted state. The amber one does not advance until a human sends a signal, and it will wait for days without holding anything open.

In code the skeleton is small. State lives in plain instance attributes, which is exactly what Temporal rebuilds on replay. A query exposes progress to a dashboard without interrupting the run.

from datetime import timedelta
from temporalio import workflow
from temporalio.common import RetryPolicy

with workflow.unsafe.imports_passed_through():
    from .activities import llm_plan, run_tool, burp_scan, exploit, write_report
    from .types import ScopeSpec, Finding, Approval

@workflow.defn
class PentestWorkflow:
    def __init__(self) -> None:
        self.phase: str = "scoping"
        self.findings: list[Finding] = []
        self.approval: Approval | None = None

    @workflow.query
    def status(self) -> dict:
        return {"phase": self.phase, "findings": len(self.findings)}

    @workflow.run
    async def run(self, scope: ScopeSpec) -> dict:
        self.phase = "recon"
        recon = await workflow.execute_activity(
            run_tool, ("recon", scope),
            start_to_close_timeout=timedelta(minutes=45),
            heartbeat_timeout=timedelta(seconds=60),
            retry_policy=RetryPolicy(maximum_attempts=4),
        )
        self.phase = "analysis"
        self.findings = await self._analyse(scope, recon)     # llm_plan + burp_scan, below

        approved = await self._await_gate()                     # the human gate, below
        if approved:
            self.phase = "exploit"
            await self._exploit_confirmed(scope)

        self.phase = "report"
        return await workflow.execute_activity(
            write_report, (scope, self.findings),
            start_to_close_timeout=timedelta(minutes=5),
        )

The run method reads like a plan of the engagement because that is what it is. The interesting parts, the analysis, the gate and the exploit, are next.

Driving Kali and Burp as activities

This is where the real work lives, and it is deliberately dull. An activity is a normal async function with a decorator. It can be as slow and unreliable as the tool it wraps, because the workflow above it does not care how many times it had to retry, only that it eventually returned.

Kali, over the harness

The Kali side goes through the MCP harness: 26 tools exposed over SSH. A single recon activity picks the right tool, checks the target is in scope, and streams the output back while heartbeating so Temporal can tell a live scan from a wedged one.

from temporalio import activity
from .mcp import kali          # MCP client to the Kali harness (SSH)

@activity.defn
async def run_tool(kind: str, scope: ScopeSpec) -> ToolResult:
    tool = pick_tool(kind, scope)              # nmap, nuclei, amass, ...
    guard_scope(tool.target, scope)            # refuse out-of-scope (see below)
    async with kali.stream(tool) as proc:      # a 40-minute nmap is fine here
        async for line in proc.stdout:
            activity.heartbeat(proc.progress)  # "I am alive, here is where I am"
            if activity.is_cancelled():         # timed out or workflow cancelled
                await proc.terminate()
                raise
    return ToolResult(tool=tool.name, raw=proc.output, target=tool.target)

The heartbeat is the whole point. If Kali stops sending progress for longer than the heartbeat_timeout set at the call site, Temporal declares the activity dead and reschedules it, without waiting out the full 45-minute start-to-close window.

Burp, over the DAST GraphQL API

Burp Suite DAST exposes a GraphQL API. You launch a scan with the create_schedule_item mutation and poll the scan query for status and issues. Two operations:

# the two Burp operations
CREATE_SCAN = """
mutation($site_ids: [ID!]!) {
  create_schedule_item(input: {site_ids: $site_ids}) {
    schedule_item { id scheduled_run_time }
  }
}"""

SCAN_STATUS = """
query($id: ID!) {
  scan(id: $id) {
    status
    issue_counts { total }
    issues(start: 0, count: 1000) { serial_number type_index severity }
  }
}"""

Wrapped in an activity, launch then poll, heartbeating on every tick:

import asyncio, httpx
from temporalio import activity

@activity.defn
async def burp_scan(base_url: str, openapi: str | None) -> list[Finding]:
    async with httpx.AsyncClient(headers=burp_auth()) as http:
        site = await ensure_site(http, base_url, openapi)   # idempotent: reuse if exists
        started = await gql(http, CREATE_SCAN, {"site_ids": [site]})
        scan_id = started["create_schedule_item"]["schedule_item"]["id"]
        while True:
            s = await gql(http, SCAN_STATUS, {"id": scan_id})
            status = s["scan"]["status"]
            activity.heartbeat(status)
            if status in ("succeeded", "failed"):
                break
            await asyncio.sleep(15)
    return [to_finding(i) for i in s["scan"]["issues"]]

One detail that will bite you: activities retry, so launching a scan must be idempotent. ensure_site reuses a site if it already exists, and you tag the scan with the engagement ID so a retry after a network blip rejoins the existing scan instead of starting a second one. More on idempotency where it actually hurts, further down.

The planner is an activity, and that is the whole trick

The model is the least reliable component in the system. It rate-limits, it times out, it occasionally refuses on content policy, and once in a while it hands back confident nonsense. So it goes in an activity, sealed off from the workflow, wrapped in a retry policy, and backstopped by a fallback model. The workflow never sees the flakiness. It asks for a plan and eventually gets one, or a clean, loud failure.

Figure 4  /  the planner never takes the engagement down
llm_plan
activity · non-determinism sealed inside
✓ ok
plan validated against scope, returned to workflow
↻ 429 / 5xx / timeout
retry with backoff, then fall back to another model
✗ auth / refusal / malformed
non-retryable, fail fast, surface it

Figure 4. Transient failures are Temporal's job. Hard failures fail loudly rather than silently corrupting the run. Nothing here reaches the workflow as a half-state.

The retry policy is set where the activity is called, not on the activity itself. Transient errors retry with exponential backoff. A short list of error types is marked non-retryable, because retrying an auth failure or a malformed plan six times just wastes ninety seconds before failing anyway.

from temporalio.exceptions import ActivityError

@activity.defn
async def llm_plan(ctx: PlanContext, model: str) -> Plan:
    resp = await llm(model).plan(ctx)     # RateLimited / AuthError / PolicyRefusal
    return validate_plan(resp)            # malformed → MalformedPlan (non-retryable)


# ...called from the workflow, with the policy and a one-shot fallback:
async def _next_step(self, ctx: PlanContext) -> Plan:
    policy = RetryPolicy(
        initial_interval=timedelta(seconds=2),
        backoff_coefficient=2.0,
        maximum_attempts=6,
        non_retryable_error_types=["AuthError", "PolicyRefusal", "MalformedPlan"],
    )
    try:
        return await workflow.execute_activity(
            llm_plan, (ctx, PRIMARY_MODEL),
            start_to_close_timeout=timedelta(seconds=90), retry_policy=policy)
    except ActivityError:                  # primary exhausted or hard-failed
        return await workflow.execute_activity(
            llm_plan, (ctx, FALLBACK_MODEL),
            start_to_close_timeout=timedelta(seconds=90), retry_policy=policy)

Six attempts with backoff rides out a rate-limit storm. A hard model outage falls through to a second provider once. Only then does the phase fail, and it fails with a full history of every attempt.

There is a subtler win here, and it is the one that matters for trust. The model is non-deterministic, but that non-determinism is trapped inside the activity. All the workflow records in its history is llm_plan returned this plan. Replay the engagement a year later and it rebuilds identically, even though the model that produced the plan would answer differently today. The audit trail is stable because the chaos is quarantined.

The model proposes. The workflow disposes. A plan is still checked against the rules of engagement before a single packet leaves the box.

The human gate

Recon and scanning can run unattended all day. Exploitation cannot. Before anything with a blast radius fires against a client's production, a human approves it. In most agent frameworks that is a fragile bit of glue: a blocking prompt, or a database row and a poller someone wrote on a Friday. In Temporal it is a first-class primitive. The workflow blocks on a condition, a signal releases it, and a durable timer covers the case where nobody answers.

Figure 5  /  approval before anything fires
Analysis
proposed exploit + evidence
🔒 GATE
wait_condition(approved) · 24h durable timer
signal: approve
→ run the exploit
signal: reject
→ skip straight to report
timer fires, no answer
→ escalate, do not exploit

Figure 5. The workflow can sit at this gate over a weekend. It holds no thread, no connection, no process. The timer and the wait survive every restart in between.

import asyncio

@workflow.signal
def approve(self, decision: Approval) -> None:
    self.approval = decision        # who, when, approved bool, note

async def _await_gate(self) -> bool:
    self.phase = "awaiting_approval"    # now visible to the dashboard via status()
    try:
        await workflow.wait_condition(
            lambda: self.approval is not None,
            timeout=timedelta(hours=24),
        )
    except asyncio.TimeoutError:
        await workflow.execute_activity(
            escalate, self.findings,
            start_to_close_timeout=timedelta(minutes=1))
        return False               # nobody answered in 24h: refuse to exploit
    return self.approval.approved

The signal can arrive from Slack, a web UI, a phone. Wherever it comes from, the approver, the timestamp and the note land in the event history. Who authorised the exploit becomes part of the evidence, not a memory.

This is also the answer to a governance question that keeps security leaders up at night. There is no override on this gate, because an override that exists gets used under deadline pressure at exactly the wrong moment. The gate is part of the machine, not a step in a runbook that a tired human can wave through. The automation physically cannot proceed to exploitation without a recorded human decision.

This is where the pentester lives

The gate is not scaffolding you remove once the model gets good enough. It is the point of contact between machine breadth and human judgment. Deciding whether an exploit is safe to fire against production, whether the finding is real, whether the chain is worth pursuing, that is the job. The workflow just makes the decision unskippable and permanent.

Rules of engagement, enforced in code

An out-of-scope target is not a retryable error. It is a full stop. The model can hallucinate a target all it likes. It hits a wall written in deterministic Python, not a polite request in a system prompt.

from temporalio.exceptions import ApplicationError

def guard_scope(target: str, scope: ScopeSpec) -> None:
    if not in_scope(target, scope):
        raise ApplicationError(
            f"target {target} outside {scope.kind} scope",
            type="OutOfScopeError",
            non_retryable=True,          # do not retry a scope violation
        )

Called at the top of every tool and exploit activity. A refusal is recorded in history with the target and the reason, which is itself proof you stayed inside the lines.

That guard is the innermost ring. Outside it, the gateway from Figure 1 blocks any tool call the agent is not authorised to make, and the just-in-time credentials never reach out-of-scope systems in the first place. If the workflow logic were somehow wrong, two more layers still hold, and each one records what it refused. Scope is not a promise in a statement of work. It is enforced three times and logged every time.

The event history is your evidence

Here is the part that turns an AI-assisted test into something you can defend. The event history Temporal keeps so it can survive a crash is, without any extra work, the most complete record of an engagement you have ever had. It looks like this.

# excerpt, PentestWorkflow event history
09:14:02  WorkflowStarted        scope=api_wide  pr=418  sha=4b1c9e0
09:14:03  ActivityScheduled      run_tool(recon)
09:41:20  ActivityCompleted      run_tool(recon)   214 hosts, 9 services
09:41:21  ActivityScheduled      burp_scan(base=api.acme.test)
10:58:07  ActivityCompleted      burp_scan          17 issues
10:58:08  ActivityScheduled      llm_plan(PRIMARY_MODEL)
10:58:10  ActivityFailed         llm_plan  RateLimited  (attempt 1/6)
10:58:14  ActivityCompleted      llm_plan           plan: probe BOLA on /v2/payouts
10:58:15  TimerStarted           gate-timeout 24h
11:03:44  SignalReceived         approve  by=alex  note="prod ok, low blast radius"
11:03:45  ActivityScheduled      exploit(finding=BOLA-/v2/payouts)
11:07:12  ActivityCompleted      exploit            confirmed, evidence stored

Every decision, every retry, every approval, timestamped and immutable. Note the rate-limit at 10:58:10 that retried and succeeded four seconds later. The engagement never noticed.

Figure 6  /  one log, two products
Event history
every scheduled + completed event
Evidence / chain of custody
what ran, when, against what, who approved · replay it a year later
SDLC KPIs
coverage · time-to-first-finding · MTTR · false-positive rate · % needing a human · analyst-hours saved

Figure 6. Because the metrics are computed from the log rather than self-reported, time-to-first-finding is literally the first finding event minus the start event. Nobody is massaging a spreadsheet.

Your numbers go here

The KPIs are emitted per run, but the baselines are yours. Drop in your real coverage percentage, your median time-to-first-finding, your false-positive rate before and after the gate, and the analyst-hours the automation gave back last quarter. Those are the numbers that turn this from an architecture into a business case.

Scaling without nuking the client

The nightly full-scope run and the api-wide scan both fan out across many targets. The wrong way is asyncio.gather over every host at once, which is a great way to knock over the thing you were hired to test. The right way is a child workflow per host, launched in bounded batches.

@workflow.defn
class FullScopeWorkflow:
    @workflow.run
    async def run(self, scope: ScopeSpec) -> list[dict]:
        results = []
        for batch in chunked(scope.targets, size=10):     # never more than 10 hosts at once
            results += await asyncio.gather(*[
                workflow.execute_child_workflow(
                    PentestWorkflow.run,
                    host_scope(scope, host),
                    id=f"pentest-{scope.engagement_id}-{host}",
                    task_queue="pentest-host",
                )
                for host in batch
            ])
        return results

Each host is its own child workflow, with its own history, its own retries and its own gate. One host falling over does not take the campaign with it, and the parent history stays small.

The real throttle is the task queue. Workers pull work from pentest-host, so sizing that worker pool caps how many host scans run concurrently across every engagement at once, not just within one. And for a long-lived campaign that watches a scope continuously, the history would grow without bound, so you call continue_as_new to start a fresh history while carrying the state forward.

if workflow.info().get_current_history_length() > 10_000:
    workflow.continue_as_new(carry_forward(self.state))   # fresh history, same campaign

The honest costs

This is not free, and pretending it is would be the kind of vendor pitch this blog exists to mock.

The determinism tax is real. You cannot reach for datetime.now() or a stray HTTP call inside the workflow. Everything with a side effect becomes an activity. It is a genuine discipline and it will trip you up in the first week, until it becomes the reason nothing ever loses state.

Idempotency is the sharp edge. Temporal executes activities at least once, which means an activity can run twice, for instance after a heartbeat timeout on a scan that was merely slow rather than dead. A recon scan running twice is wasteful. A destructive exploit running twice is an incident. So exploit activities carry an idempotency key per finding, and the genuinely dangerous ones sit behind the human gate, where a double-fire would need a double-approval that is never going to happen by accident. Design this in from the start. It is the one place the model of at-least-once bites hard.

You now run infrastructure. A Temporal cluster or Temporal Cloud, workers, task queues, a namespace. For a solo consultant chasing one engagement that is heavier than a Python script and probably not worth it. For a team running continuous, change-triggered testing across an estate, it pays for itself the first time an engagement survives a 3am worker reboot that would otherwise have cost a day.

The harness is still yours. Temporal makes the MCP tools, the Burp integration and the plan validation reliable. It does not make them exist. That work is still on you, and the plan-validation layer in particular needs care, because it is the thing standing between a hallucinated plan and a real packet.

Why the pentester is still the point

Step back from the plumbing and look at what the machine is actually good at, because the honest version of this argument needs both halves.

The autonomous tools are strong at the front of the pipeline. Given a one-day CVE with the advisory in hand, a current model exploits it around 87% of the time. Take the description away and that falls to 7%, which tells you precisely what kind of strong it is: fast at applying what is already written down. Recon chaining is reliable. SSRF and injection are the highest-performing classes across every benchmark. None of it gets bored on host two hundred.

Now the other half. Point the same class of agent at real vulnerabilities instead of sanitised lab ones and success drops to about 13%, and to nearly zero on hard targets. Roughly 70% of critical web vulnerabilities are business logic flaws, and no autonomous agent detects those reliably, because a business logic flaw only exists in the context of how your business is meant to work. Chained exploitation across five or more conditional steps is still out of reach. The first head-to-head test on a live enterprise network, 8,000 hosts, had the AI find 9 valid issues while the best human found 13, at around 18 dollars an hour of value against 60 for the professional.

The headline case proves the rule. XBOW topped HackerOne's US leaderboard with over a thousand submissions in a few months. Read past the headline: 132 were confirmed and resolved, hundreds were duplicates or informative, and every finding was reviewed by human staff before submission. The autonomous tool that beat the humans still ran on humans.

So the shape of the win is not subtle. The AI does the volume. The human owns the call. Durable execution is what lets you run the two together across a real engagement, ride out the model's bad days, hold the exploit until a person says go, and hand over a log you can replay line by line. The gate in Figure 5 is not the automation's weakness. It is the automation being honest about where judgment belongs.

Build the backbone first

One thing to take away: a model without durable execution is a demo, and a demo is not an engagement. Build the nervous system before you fall in love with the brain. Wrap the workflow in Temporal, put every model call and every tool call in an activity, gate the dangerous phase behind a human signal, and let the event history be both your crash recovery and your evidence.

Do that and the AI stops being a party trick and becomes a force multiplier you can bill for. The pentester goes back to doing the part that was always the actual job. And the client gets a report backed by a trail you can replay a year later, which is the only kind of trust that survives contact with a real audit.

My harness started as the brain. Temporal is the nervous system that made it worth pointing at anything that matters.

Sources & further reading

Temporal · Durable Execution meets AI
XBOW tops HackerOne · TechRepublic
AI pentesting agents, 2026 field data · AppSec Santa research
Burp Suite DAST GraphQL API · PortSwigger docs
Temporal Python SDK · official docs

23/08/2026

Forty Minutes, Five Months

Forty minutes. That is how long two malicious LiteLLM releases sat on PyPI back in March. 24 March, 10:39 UTC, versions 1.82.7 and 1.82.8 go live. Around 11:19 they are gone, and the project told anyone who installed before 16:00 UTC that day to assume the worst.

As I write this, five months later almost to the day, credentials stolen in those forty minutes still work.

The malware was competent but ordinary. The gap is the story.

The chain

Start at the entry point, because it should make you uncomfortable. Nobody phished a LiteLLM maintainer. The attacker came in through Trivy, a vulnerability scanner, compromised earlier in March in a separate supply chain attack. A PyPI API token got exposed through the compromised Trivy dependency, and a publishing token is a skeleton key. You do not open a pull request. You do not pass review. You do not touch the project's CI at all. You push straight to the index and the index says thank you.

So the release process everyone trusts, the pipeline with the gates and the checks, was bypassed by design. The attacker held the one credential that sits above all of it.

Then there is the payload delivery, which deserves more attention than it got. Version 1.82.8 shipped a file called litellm_init.pth. Python has an ancient feature where .pth files in site-packages execute at interpreter startup. Not at import. Startup. Once that file is on disk, every Python process on the machine runs the payload. Your linter runs it. Your database migration runs it. You never import litellm once and it makes no difference.

On a CI runner that means the harvest happens at job start, when the environment is at its richest. Environment variables, SSH keys, cloud credentials, Kubernetes tokens, database passwords. And because this is 2026, the model keys too, OPENAI_API_KEY and ANTHROPIC_API_KEY sitting right there in env.

TIMELINE
24 Mar 10:39 UTC  . . .  1.82.7 / 1.82.8 live on PyPI
24 Mar ~11:19 UTC  . . .  pulled. exposure window: ~40 min
13 Aug  . . . . . . . .  Hudson Rock publishes the fallout
today  . . . . . . . . .  tested credentials still valid

What forty minutes buys

Hudson Rock put numbers on it in August. A 153GB archive. 433,909 files. 118,829 CI runner dumps, tied to 2,488 corporate domains. CloudSEK maps the exposure to more than 2,500 organisations, and the names in the pile are not small: NVIDIA, Cisco, Deloitte, Volkswagen, FedEx, Siemens, X Corp.

Read those numbers against the window. Nobody triaged anything in forty minutes. No human saw an alert and made a decision inside that window. Automated builds pulled the poisoned versions, executed the payload at interpreter startup and shipped their own secrets out, at machine speed, on the attacker's behalf. The attack was over before the defenders' day started.

The five months

Here is the part that should actually scare you. PyPI pulled the packages, LiteLLM published an advisory, and for most affected teams that was the end of the incident. Quarantine got treated as closure. The artifact is gone, therefore the problem is gone.

Except the incident does not end when the package dies. It ends when the credentials the package saw are dead. Those are different events, and the distance between them turned a forty minute compromise into a five month one.

One researcher checked an organisation that claimed it had rotated everything. Almost every credential he tested still worked.

That tracks with everything we know about rotation. Finding secrets is a solved problem. Rotating them is not, because rotation means knowing what breaks when the value changes, and that knowledge is usually in one engineer's head, and that engineer may have left. So the dashboard says remediated while the attacker's copy keeps working.

Rotate on exposure. Not on confirmation. If the artifact ran where your secrets live, the secrets are gone. Behave accordingly.

Your scanner is a dependency

Sit with the entry point again. The ingress for one of the worst CI/CD compromises of the year was a security tool. Trivy sits inside build pipelines on purpose, with publishing rights and tokens in reach, and we extend it a trust we would never extend to a random utility library. There is no technical basis for that trust. A scanner is code you pull from the internet, running with more privilege than the code it inspects.

Threat model your security tooling like any other dependency, because the attackers already do.

What actually stops this

In our pipeline the control that turns this whole incident into a non event is boring: a minimum package age. Installs route through a proxy that refuses anything published less than 48 hours ago. The malicious versions lived for forty minutes. Under a 48 hour hold, nothing in the building could have pulled them, not a dev shell, not a runner, and nobody had to be fast or even awake. The window closed because the window was never open.

The hold costs something. Occasionally a fix you genuinely need shipped that morning and someone has an argument with the proxy. That argument is the price, and against 118,829 runner dumps it is nothing.

Beyond that, the homework this incident sets is short. Grep your build logs for litellm 1.82.7 and 1.82.8 around 24 March, and treat any hit as full compromise of everything that environment held. Go and look at what your CI runners actually expose in env at job start, because that inventory is precisely what got dumped here. Audit site-packages for .pth files you cannot explain. Then look hard at how many of your credentials are long lived, because a stolen token that expires in an hour is a very different artifact from one that still works in August.

Forty minutes of attacker effort. Five months of defender debt. The ratio is the lesson, and it only moves in one direction: mechanically, before the window opens, because nothing human moves inside it.

Sources:

The Hacker News: malicious LiteLLM releases tied to Trivy compromise (CloudSEK)

Help Net Security: Hudson Rock on the stolen credential leak

14/08/2026

The controls your developers never see

The controls your developers never see

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.

Fig 1 — the pipeline, end to end
Author surface
DEV+ agents
ANALYSTnon-engineer
no security tooling here. nothing to install, nothing to remember.
Local, transparent
SAFE CHAINpkg proxy
48Hmin age
GITGUARDIANpre-commit
COMMITsigned
CI — the enforcing layer
AIKIDOSCA · IaC · SAST
GITGUARDIANCI, unskippable
SIG VERIFYkey + identity
PR GATEgreen or blocked
MERGE
autofix PR returns to author surface as an ordinary diff
AI layer — feeds the same gates
CONTEXTcodebase rules
WILLOWagent gateway
OPALJIT identity
SKILLS REPOOWASP encoded
CLAUDE HOOKStool-call guard
The top band is the important one. It is almost empty by design, and that emptiness is the entire thesis.

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.

Fig 2 — Safe Chain sits between the installer and the registry
DEV SHELLnpm · pip · uv
AI AGENTruns installs
CI CONTAINERdocker build
VM IMAGEephemeral
SAFE CHAINlocal proxy
shell alias wrap
AIKIDO INTELmalware check
AGE < 48H?hold
REGISTRYnpm · PyPI
The agent does not know it is proxied. It runs 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.

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.

Why this got worse

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.

Fig 3 — Willow gateway, every action inspected
CODING AGENT
OPS AGENT
ANALYST AGENT
WILLOW GATEWAYidentity → connection
→ scope → risk
MITRE REGEXinjection guard
PII REDACTegress
ALLOW
REDACT
HUMAN OK
BLOCK
JIRAuntrusted text in
REPOS
CLOUD APIs
MODELS
Untrusted content arrives from the right-hand side as readily as instructions arrive from the left. The gateway is the only place both are visible at once, which is why the injection check belongs here and not at the model.

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.

Fig 4 — hook-level guards on the tool call
AGENTtool call
INJECTION CHECKis this instruction
from content, not user?
DESTRUCTIVE CMDrm -rf · force push
drop · terraform destroy
SHELL SANDBOXno net · scoped fs
ephemeral
EXECUTE
DENY + LOG
Log the tool calls, not the prompts. Prompt logs tell you what someone asked for. Tool call logs tell you what happened, and during an incident only one of those is evidence.

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:

TechniquePatternAction
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.

Where this is weakest

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

ControlVisible to author?Real friction
Aikido SCA / IaC / SASTOnly on a findingLow
Aikido autofixAs a PR to reviewNear zero
Safe Chain proxyNoNear zero
48h minimum ageOnly when blockedOccasional real complaint
GitGuardian pre-commitOnly when it firesSeconds
GitGuardian CI gateOnly on failureBlocks merge, correctly
PR gateOne status checkNear zero
Signed PRs + rotationSetup, then 90-day rotateHigh at rollout, low after
Codebase contextNoMaintenance burden on us
Willow + MITRE regexNo, unless escalatedLatency, small
Opal JITRequest flow for agentsLow, once wired
Skills repoYes, they contributeReview load on AppSec
Claude hooksOnly on denyNear 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.


The AI pentest that survives day three: a Temporal backbone for agentic offensive security

The AI pentest that survives day three: a Temporal backbone for agentic offensive security The AI pentest that survives day three ...