All lessons › Function C — Application Security with an AI SDLC › The AI SDLC — an Agentic AppSec Pipeline, Before and After Deploy
C2.10

Agentic penetration testing — the loop, and who runs each turn

C2
▶
Lightboard recording

Not recorded yet. The lesson below is complete and runnable today — the video is added later without touching this page.

Video placeholderpublishes automatically from recordings/C2.10.mp4
Risk

Payload suggestions instead of attack chains — and an offensive loop with no hard scope enforcement, which is an incident with a project plan.

Control

Full target context before it swings, and scope enforced at the network layer rather than by a politeness clause in the prompt.

The lab

Drive a planner/executor pair against a local target and watch the scope guard refuse an out-of-scope host before the request leaves.

◉Use case relevance

Supabase has never been breached. Applications built on it leak constantly, and always through the same two doors — a table with no row policy, and an admin key in a frontend bundle. One write-up puts it at 73% of generated applications carrying at least one issue.

At CyberTravels. appsec/pentest.py. Scope.check() is called on every request and lives outside the loop, which is the lesson: "only test the hosts below" is a request addressed to the component an attacker is trying to influence, and it is the first thing to go when the loop finds something one hop away.

◴What this lesson is — Day 0, Day 1, Day 2

The offensive loop, and the control that has to sit outside it. Scope enforced at the request boundary rather than requested in a prompt, because a prompt-level rule is addressed to the component an attacker is trying to influence.

Day 0 — why

An offensive loop with no hard scope boundary tests something you were not authorised to touch, at machine speed.

Day 1 — how

Give the loop full target context, and enforce scope at the network layer rather than by asking the model to respect it.

Day 2 — measure

Findings ranked against a severity-sorted baseline, and the number of out-of-scope requests the enforcement refused.

Measure this

what to measuredenominator or target
findings ranked against a severity-sorted baselinerank correlation
out-of-scope requests the enforcement refusedcount

▦The framework, and how it works

   two critical patterns, and they are not degrees of one thing

   RLS disabled on a table        service_role key in the frontend
   removes the POLICY             removes the POLICY ENGINE
   anon key reads that table      admin key reads every table

   why it recurs
   generator -> create table ... (no policy attached)
             -> frontend works perfectly
             -> every test passes
             -> nothing in the suite is shaped like the question

   the two fixes, and only one survives the next deadline
   per table   alter table ... enable row level security
   by default  not exposed through the Data API unless opted in   <- 2026

Penetration testing has always been a loop: recon → hypothesis → test → escalate → report. What has changed is who runs each turn.

Manual (still the baseline). A human runs nmap, reads the output, forms a hypothesis, tries it. Slow, and the quality is entirely the tester's.

Scripted. The recon is automated — Nuclei templates, a Burp scan. The hypothesis and the escalation are still human. This is where most teams are.

Semi-autonomous. An open-weight model reads the recon output and proposes which findings are worth chasing and what to try next. The human approves each action. The gain is triage speed on a large surface: 400 findings ranked in minutes rather than a day.

Autonomous. The model proposes and the harness executes, within a pre-approved scope and tool set, verifying its own results. This is real and it works, and it is also where the engagement becomes a safety problem — because an agent that has not understood the scope will happily test something outside it at machine speed.

The professional obligations do not change with autonomy. They get harder, because scope enforcement can no longer live in the tester's attention — it has to live in the harness, and then underneath the harness in the network.

The three lessons after this one are the same loop run with different access: white box with the source (C2.11), black box from outside with no credential (C2.12), and grey box with one credential per role (C2.13). What changes between them is not the tooling — it is what a finding is allowed to claim, and C2.14 is the controls all three run inside.

That second half is why containment belongs in this lesson rather than in a later one. An offensive harness has a property no other agent has: everything it reads is hostile by design. Banner strings, error bodies, file contents — all of it comes from a system you are attacking, which may itself already be attacker-controlled. Containment there protects three parties at once: the client (scope and rate limits, so you do not break their production), everyone else (egress control, so a compromised harness cannot pivot outward), and you (findings and client data must not leave by a route the agent chooses).

2 · Demo — the four generations on the same recon output

Realistic scan output from an authorised engagement against hosts you own. The question at every generation is the same: what do I chase first?

3 · Where it breaks — generation 4, and the scope problem

The model's top-ranked item is correct. Its reasoning on F-06 is also correct — out of scope, do not touch. Now make it autonomous and remove the human from the loop. What stops it acting on a finding it has correctly identified as out of scope?

Nothing in the model. Its judgement about scope is a proposal, on the decision plane, exactly like everything else it produces.

4 · The control — and the layer underneath it

The scope check above lives in the harness, which is one process away from the loop it constrains. On an engagement a single control is a single point of failure, and the failure is a professional incident. The same rule therefore gets restated where the agent cannot reach it: the sandbox's own request path.

5 · The procedure, as a skill

Model triage beats severity sorting on CyberTravels' findings and correctly calls the partner CDN out of scope — and can be argued into calling it critical. The skill runs both, then re-runs with scope enforced outside the model, where the persuaded model still proposes the call and nothing acts on it.

▶Real time execution as skill

The skill — skills/redteam/offensive-agent-containment/SKILL.md

name: offensive-agent-containment
description: >-
  Run an offensive agent's triage inside an enforced engagement scope, and check
  what the enforcement does when the model is adversarially convinced that an
  out-of-scope host is critical. Use when giving an agent offensive capability,
  or when scope is currently a sentence in a statement of work.
allowed-tools: Read, Grep, Glob

Scope enforced outside the model, or not enforced

A model triaging pentest findings beats severity sorting: it reads the finding and reasons about exploitability, including that the partner CDN is out of scope. That is a good reason to use one and a bad reason to trust it, because the same reasoning can be argued with. Containment is the part that cannot.

When to use this

Any agent with offensive capability — scanning, exploitation, recon — and any workflow where scope is enforced by asking the model to respect it.

Procedure

1 — Establish the baseline you are improving on. Sort by severity, take the top n, and count how many exploitable findings you caught. This is the number model triage has to beat, and it is usually beaten.

2 — Run the model's triage and record its reasoning. Including the scope call. Note that it is correct — the argument here is not that the model is bad.

3 — Adversarially convince it. Craft the finding so the out-of-scope host looks critical. A capable model will be persuaded, because being persuadable by evidence is what makes it useful.

4 — Re-run with scope enforced outside the model. An allow-list of hosts, plus a check on private ranges and the metadata address, evaluated on the action rather than on the plan. The persuaded model still proposes it; nothing acts on it.

5 — Report both runs. Unenforced and enforced, on the same findings. The comparison is the deliverable: the model's judgement improved triage and did not provide containment, and those are separate purchases.

Example

Input — the fixture committed at the top of scripts/offensive_agent_containment.py. Edit it and re-run: the buckets, counts and verdicts below are derived from it, not hard-coded.

Output — the opening lines of a real run:

=== generation 1: manual — a human reads all six and decides ===
   6 findings, no ordering, ~20 min of reading

=== generation 2: scripted — sort by severity ===
   F-02 high    /v1/users returns data without auth
   F-01 medium  TLS 1.0 enabled
   F-04 medium  password auth permitted
   → severity is a label, not a prediction. F-04 and F-05 are both

The run continues past this. test_skills.py executes the script on every build, with no model configured — so what CI proves is that it runs and refuses legibly, not that it prints these lines. The output above is a recorded run against a real model, and nothing re-diffs it: a model's answer is not reproducible, and this repository does not claim otherwise anywhere it can be checked.

Output contract

{
  "baseline": {"method": "severity", "top_n": 0, "exploitable_found": 0},
  "model_triage": {"top_n": 0, "exploitable_found": 0, "scope_calls": [{"host": "str", "in_scope": false}]},
  "adversarial": {"payload": "str", "model_convinced": true},
  "enforced": {"scope": ["str"], "blocked": ["str"], "reason": ["allow-list", "private range", "metadata"]},
  "conclusion": {"triage_improved": true, "containment_from_model": false}
}

Failure modes

  • Enforcing scope in the prompt. It is a request, and the adversarial case is precisely one that argues with requests.
  • Checking the plan rather than the action. The plan is text; the action is where the allow-list applies.
  • Concluding the model is untrustworthy. It improved triage. It is not a control, which is a different sentence.

Three ways to do this lesson. Pick one. They run the same procedure against the same code — what changes is who drives it, and how much you see.

1In your AI agent — you are taught

The agent reads the lesson, explains the idea, fetches the code, runs the check and answers your questions as you go. This is the one to take if you are here to learn.

Install it, once. From the folder you cloned. --all does every agent it finds on your machine:

python3 scripts/install_skills.py --all --lessons C2.10

Then restart your agent and open it in that folder. On Windows, if python3 says Python was not found, use python instead.

Then pick c2-10-agentic-penetration-testing:

agentinstall just for this agentstart it
Claude Codepython3 scripts/install_skills.py --tool claude --lessons C2.10type /c2-10-agentic-penetration-testing
GitHub Copilot (VS Code)python3 scripts/install_skills.py --tool copilot --lessons C2.10type /c2-10-agentic-penetration-testing in Chat
Cursorpython3 scripts/install_skills.py --tool cursor --lessons C2.10type / in Agent chat and search for it
Codexpython3 scripts/install_skills.py --tool agents --lessons C2.10type $c2-10-agentic-penetration-testing, or run /skills

2As one Python command — you just run it

No agent, no conversation. The same lesson executes and prints the same readback. Take this one if you have no assistant, if you are reviewing rather than learning, or if you want the result without the teaching around it.

python3 scripts/lesson.py C2.10

3Read the code, and the difference — nothing runs

Neither of the above, and no model needed. The first command writes CyberTravels exactly as it stood at the end of this lesson; the second prints the diff — the lines this lesson added or changed, and nothing from any later one.

python3 scripts/checkpoint.py --at C2.10 --out work/cybertravels
python3 scripts/checkpoint.py --at C2.10 --diff

On Windows, type python wherever these say python3 — and use \ instead of / in the paths. Everything else is identical. A0.0 step 2 explains why.

Ways 1 and 2 need a model; way 3 does not. A signed-in Claude Code CLI needs no API key, and any OpenAI-compatible endpoint works too. With neither, the audit exits 2 and says so rather than inventing an answer. A0.0 sets this up.

Ways 1 and 2 both end with the same readback: What I did, What changed, The number and Read this next. A step that was skipped or refused is reported as exactly that, never as a pass.

If you did the last lesson’s challenge, expect this to stop. Most challenges ask you to edit the code, and ways 1 and 2 will not overwrite your edits: you get stopped, not a failure, with two options — --out for a fresh folder that keeps your work, or --force to discard it and start this lesson clean.

✓What you just proved

Severity sorting puts 2 of 3 exploitable findings in the top 3; model triage puts 3 of 3, and correctly reasons that the partner CDN is out of scope. With the model adversarially convinced that the out-of-scope host is critical, the unenforced harness acts on it and the enforced harness refuses. Underneath the harness the sandbox refuses three requests for three different reasons — rate limit, engagement boundary, and cloud metadata — without consulting the model at all.

✎Your turn

Write your engagement scope as a data structure your harness reads, not as a paragraph in a PDF. Then ask what your current tooling would do if a target redirected to a host you were not authorised to touch.

Tools used

CAIMetasploitFirecrackerKimi K2GLM-4.6Claude Sonnet 5

Skill source: skills/redteam/offensive-agent-containment

← PreviousC2.9 · Exploit chaining