Red Teaming LLM Applications and Agents

Suraj Kumar

Suraj Kumar

Last updated November 9, 2026·22 min read

AI Red Teaming course · lesson 24 of 26
  1. 1What Is AI Red Teaming?
  2. 2Ethics, Authorization, and Rules of Engagement
  3. 3AI and Machine Learning Basics for Red Teamers
  4. 4Security Principles and AI Threat Modeling
  5. 5Build a Safe AI Red Team Test Lab
  6. 6Jailbreaks and Safety Filter Bypasses
  7. 7Prompt Injection: Direct and Indirect
  8. 8Code Injection and Tool Abuse
  9. 9System Prompt and Data Leakage
  10. 10Countermeasures for Prompt Attacks
  11. 11Data Poisoning and Backdoors
  12. 12Adversarial Examples
  13. 13Model Extraction and Weight Theft
  14. 14Model Inversion and Privacy Leaks
  15. 15Model Manipulation and the AI Supply Chain
  16. 16AI Infrastructure, Deserialization, and Remote Code Execution
  17. 17API Authentication and Authorization
  18. 18Black, Grey, and White Box Testing
  19. 19Manual, Automated, and Continuous AI Testing
  20. 20AI Red Teaming Tools, Frameworks, and Datasets
  21. 21Write Your Own AI Security Test Harness
  22. 22Report AI Security Findings and Track Fixes
  23. 23Defense in Depth for AI Systems
  24. 24Red Teaming LLM Applications and Agents
  25. 25Disclosure, Law, Careers, and What's Next
  26. 26Capstone: Red Team a Practice App
View full syllabus

After this lesson, you will be able to scope a red-team test for an LLM application, map its risks to the current OWASP lists, plan safe checks for tools and agent connections, prioritize the first tests, and report gaps your lab cannot cover. The code validates a fictional, offline HelpDesk Bot test plan. It does not connect to a model, MCP server, or production service.

An LLM application combines a language model with software such as an interface, account system, retrieval layer, or database. An agent is an application that can choose steps or call tools to carry out a task. Those added capabilities change what a test needs to examine. A text answer may be harmless by itself, while the same answer passed to a ticket, email, or file tool can create a side effect.

This lesson uses the local HelpDesk Bot fixtures and the OWASP GenAI LLM Top 10 2026 and Top 10 for Agentic Applications 2026, current as of 6 November 2026. The lists give teams shared names for risks. They are not a complete test plan for every system. Start from the application's intended use, its data, and the actions it can take.

The current OWASP LLM risk areas connect to test questions at input, retrieval, model, tool, and output boundaries, with each test marked automated, manual, or out of scope.

Map the application before choosing tests

Draw the request path in plain language. For HelpDesk Bot, a customer submits a question; the application checks the account; retrieval may return help content or a ticket; the model prepares a response; and a tool may create a support ticket after confirmation. Record where each part runs and what identity or credential it uses. Include background jobs, saved conversation state, external services, and human review if they affect the task.

Mark the trust boundaries, where information or authority moves between components. A customer's text crosses into the application. Retrieved text crosses into model context. A model proposal crosses into a tool. A delegated task crosses from one agent to another. These boundaries make useful test points because assumptions can change there: the sender may have a different identity, data may have a different owner, and a suggestion may acquire permission to act.

Write down the asset and failure condition for each boundary. If the asset is a ticket belonging to Tenant B, the failure condition might be that a user from Tenant A receives its synthetic marker. If the asset is the ability to create tickets, the failure condition might be an action occurring without the approved confirmation. State what evidence will demonstrate the result, such as an authorization decision, a tool event, a change to dummy state, or a grader outcome.

This is where the local practice kit helps. Earlier tests use a planted canary, two synthetic ticket owners, an in-memory ticket tool, and a small harness. They test a few exact properties. The fixture has no persistent memory, model provider, MCP server, or multi-agent workflow. A good plan makes those missing surfaces visible instead of pretending the existing checks cover them.

Use the current OWASP lists as a map

OWASP's LLM Top 10 2026 names ten risk areas. For each one, turn the category into a question about a component and an observable outcome:

OWASP LLM riskHelpDesk Bot test questionUseful evidence
LLM01 Prompt InjectionCan direct input, retrieved content, or a tool result change the intended task?Case result, retrieved source, and any downstream action event
LLM02 Sensitive Information DisclosureCan one synthetic account receive another account's ticket marker?Identity, object owner, and response grader
LLM03 Excessive AgencyCan the model's proposal cause a ticket action without the required approval?Proposed action, approval decision, and resulting dummy state
LLM04 Supply ChainDid a model, dependency, prompt package, or tool change outside the reviewed process?Version, provenance record, integrity check, and change history
LLM05 Data and Model PoisoningCan altered training or retrieval data steer a later result?Data source, version, controlled evaluation, and output difference
LLM06 Unbounded ConsumptionCan retries, long inputs, or tool loops exceed the agreed resource budget?Request and tool counts, limits, and stop behavior
LLM07 MisinformationDoes a wrong answer get presented as verified or trigger a consequential step?Source comparison, uncertainty behavior, and downstream decision
LLM08 Hidden Context ExposureDoes the response expose a planted hidden-context marker?Canary grader and the tested output path
LLM09 Vector and Embedding WeaknessesDoes retrieval return a document the requesting account cannot access?Query, result identifiers, access decision, and tenant labels
LLM10 Improper Output HandlingIs model output parsed, rendered, or passed onward in a way that changes its meaning?Structured output validation and safe rendering or tool events

The names above follow the 2026 OWASP LLM list. OWASP says that risks move across the boundary between a model used as a component and an agent that takes actions with tools or memory. Read both lists when the system can act. A risk name is a prompt for investigation, not proof that every system has that weakness.

The test question must match the feature. For example, a HelpDesk Bot that does not use embeddings has no vector database to probe. Record that the category is not applicable to the scoped design and why. If the application has retrieval, test tenant filtering and document ownership; a generic similarity search result is not enough to show that access controls were honored.

Do not combine different outcomes into one "secure" score. A passing canary test says that this marker did not appear in those runs. A separate test is needed to establish that the right user was authenticated, that retrieval enforced ownership, or that no unauthorized tool action occurred. Keep these claims at the level of evidence collected.

Add agent-specific paths

Agent systems add decision and delegation paths around the same application components. A model may plan several steps, select among tools, reuse memory, call another agent, or connect through a protocol such as MCP. The security question becomes: which input influenced the decision, whose identity was used, and what effects followed?

An agent reads user and retrieved content, consults memory, calls tools or an MCP server, and delegates work, creating separate trust boundaries to test.

Excessive agency means the system has more ability to act than the task requires, or can take consequential actions from unexpected model behavior. Test a safe dummy action first. Check that the tool accepts only its allowed action and parameters, that it applies the caller's permissions, and that approval refers to the exact action. Count the resulting state changes. A refusal message does not prove that an action did not already happen.

Memory poisoning is untrusted information being saved in persistent memory so it can affect later requests or users. It differs from a one-turn prompt injection because the influence can survive the original interaction. A safe test needs an isolated memory store filled with synthetic entries, separate test identities, a way to remove the entries, and explicit permission to test cross-session behavior. Check who can write, who can read, how entries are attributed, and whether a user can cause instruction-like content to persist. The current HelpDesk fixture does not have persistent memory, so the sample plan marks that test out of scope.

Multi-agent trust covers how systems delegate tasks and handle messages from other agents. Treat every agent as a separate security principal. A delegation should carry a verified identity and only the permissions needed for that subtask. A recipient should reject unregistered senders, stale or replayed messages, and tasks that exceed the sender's authority. Test that one agent cannot use a shared credential or a trusted label to gain another agent's access. Check how a failure in one worker affects the orchestrator and other work. The course fixture has no agents communicating with one another, so those checks need a separate synthetic setup.

Test MCP connections as software and authority boundaries

The Model Context Protocol (MCP) is a protocol that lets an AI application discover and call tools or access context provided by separate servers. A server can be a local process or a remote service. It becomes another component in the application's trust and supply chain. Its tool names, descriptions, input schemas, returned content, credentials, and server implementation all deserve review.

For an authorized MCP environment, inspect which servers are configured and how they were obtained. Compare tool definitions with the reviewed version and note unexpected changes. Check the permissions granted to each server, whether a remote transport authenticates and authorizes requests, and whether credentials are scoped to the correct resource. The MCP specification's authorization support is optional at the protocol level; when HTTP authorization is used, the specification describes its OAuth-based requirements. A test should confirm the actual deployment's configuration rather than assume that MCP itself supplies application authorization.

At invocation time, treat model-generated arguments and server-returned content as untrusted. Validate each argument at the server or application boundary, recheck the caller's permission, and use a harmless destination or dummy state. If one MCP server can influence how another is called, test the cross-server boundary. Review local server launch commands, filesystem and network permissions, and how the client asks for installation consent. Never install an unknown server or point a test at a real credential or service just to complete a checklist.

The current local kit does not launch MCP servers. The example plan records that gap and does not attempt to install one. For protocol concepts and the architecture of a server, see MCP concepts for agents and how to build and deploy an MCP server. For worker delegation and orchestration patterns, see orchestrator, workers, and multi-agent systems. These lessons explain construction; this one focuses on how to define authorized security checks around those components.

Set scope, authorization, and stopping rules

Write the scope before running any test. Identify the exact target, environment, test identities, approved time window, permitted data, allowed actions, and evidence location. Name the person who can authorize the work and the contact who can stop it. If a service, server, dataset, or account is not named, treat it as out of scope until the owner approves it.

Use the synthetic HelpDesk Bot plan as a model. It names one offline target, allows only fictional prompts and an in-memory ticket tool, excludes hosted providers and real records, and defines stop conditions. "Written authorization" is a field in the example record, not a substitute for actual permission. For any real engagement, store the authorization and rules of engagement through the owner's approved process.

Set stop conditions that can be observed. Examples include unexpected network access, real personal data appearing, a test affecting shared state, or a tool reaching a destination outside the plan. Also decide what to do if the target behaves differently from its documentation. Stop, preserve only the minimum safe evidence, notify the named contact, and clarify scope before continuing.

The tester should know how a successful check could have an effect before submitting it. For an action test, use a stub or instrumented tool that only changes dummy state. For a data test, use a canary planted for the exercise. For rate or loop checks, use strict limits and a stop condition. A test whose success criteria include sending email, modifying customer data, or reaching an unapproved host is not a safe practice exercise.

Prioritize what to test first

Start with the paths that combine sensitive data, untrusted input, and a meaningful action. A useful first pass for HelpDesk Bot is:

  1. Authorization and retrieval: try the synthetic cross-tenant case and confirm both that another user's marker stays hidden and that the owner's own ticket still works.
  2. Tool permission and confirmation: send the harmless ticket marker through the stub, then check that the proposal stays pending without confirmation and cannot invoke a different action.
  3. Direct and retrieved input: use only the lab's harmless marker cases. Check user text and an injected synthetic article separately, then inspect tool events as well as the final text.
  4. Canary and context boundaries: use the planted fake marker to check what the caller receives and what the logs retain.
  5. Baseline behavior: confirm ordinary requests still work. A defense that blocks every request is not a useful fix.
  6. Uncovered features: if the deployed design has memory, MCP servers, multiple agents, image or audio input, or external destinations, add those paths after obtaining explicit scope and creating an isolated fixture.

This ordering is a starting point, not a universal ranking. An image assistant, coding agent, or retrieval application has different first risks. Use the asset value, exposed path, attacker access, potential impact, and cost of a safe test to adjust the order. Keep the reason for your priority in the plan so a reviewer can challenge or update it.

For every case, record the target version, identity, setup, input source, action taken, expected behavior, observed behavior, and evidence. Mark coverage as automated, manual, or out of scope. Automated means a repeatable local case or approved runner checks it. Manual means a person needs to interpret the result or inspect a flow that is not instrumented. Out of scope means it was not tested; give the reason. "Not tested" is a useful result when it prevents a false assurance claim.

The sample plan validator below enforces the offline fixture, written authorization field, stop conditions, recognized OWASP 2026 references, local harness case IDs, and an explanation for gaps. It orders cases by the priority agreed in the plan. It does not decide severity, infer exploitability, or run any test. It is a planning aid, not an authorization system.

Validate the sample test plan

Save the two Python files and the JSON example below in one empty folder. The script uses only the Python standard library. Public functions have type hints, and all example data is fictional.

engagement_plan.py

python
1"""Validate and prioritize a synthetic, offline AI red-team plan."""
2
3from __future__ import annotations
4
5import argparse
6import json
7from pathlib import Path
8from typing import Any
9
10AUTHORIZED_TARGET = "local-helpdesk-fixture"
11PRIORITY_RANK = {"critical": 0, "high": 1, "medium": 2, "low": 3}
12COVERAGE = {"automated", "manual", "out_of_scope"}
13KNOWN_CASES = {
14    "cross-tenant-ticket-canary",
15    "system-canary-not-returned",
16    "ordinary-request-response",
17}
18RISK_REFERENCES = {
19    *(f"LLM{index:02d}:2026" for index in range(1, 11)),
20    *(f"ASI{index:02d}:2026" for index in range(1, 11)),
21}
22REQUIRED_CASE_FIELDS = {
23    "test_id", "risk_refs", "priority", "coverage", "objective", "evidence", "linked_cases"
24}
25
26
27def validate_plan(plan: dict[str, Any]) -> list[str]:
28    """Return plan problems before a tester begins an authorized exercise."""
29    errors: list[str] = []
30    engagement_id = plan.get("engagement_id")
31    if not isinstance(engagement_id, str) or not engagement_id.strip():
32        errors.append("engagement_id must be non-empty text")
33    scope = plan.get("scope")
34    if not isinstance(scope, dict):
35        return ["scope must be an object"]
36    if scope.get("target") != AUTHORIZED_TARGET:
37        errors.append(f"target must be {AUTHORIZED_TARGET!r}")
38    if scope.get("environment") != "offline":
39        errors.append("environment must be offline")
40    if scope.get("written_authorization") is not True:
41        errors.append("written_authorization must be true")
42    for field in ("in_scope", "out_of_scope", "stop_conditions"):
43        value = scope.get(field)
44        if not isinstance(value, list) or not value or any(not isinstance(item, str) or not item.strip() for item in value):
45            errors.append(f"scope.{field} must be a non-empty list of text")
46
47    cases = plan.get("test_cases")
48    if not isinstance(cases, list) or not cases:
49        return errors + ["test_cases must be a non-empty list"]
50    seen: set[str] = set()
51    for index, case in enumerate(cases):
52        prefix = f"test_cases[{index}]"
53        if not isinstance(case, dict):
54            errors.append(f"{prefix} must be an object")
55            continue
56        missing = REQUIRED_CASE_FIELDS - case.keys()
57        if missing:
58            errors.append(f"{prefix} missing fields: {', '.join(sorted(missing))}")
59            continue
60        test_id = case["test_id"]
61        if not isinstance(test_id, str) or not test_id.strip() or test_id in seen:
62            errors.append(f"{prefix}.test_id must be non-empty and unique")
63        else:
64            seen.add(test_id)
65        if not isinstance(case["priority"], str) or case["priority"] not in PRIORITY_RANK:
66            errors.append(f"{prefix}.priority must be critical, high, medium, or low")
67        if not isinstance(case["coverage"], str) or case["coverage"] not in COVERAGE:
68            errors.append(f"{prefix}.coverage must be automated, manual, or out_of_scope")
69        if not isinstance(case["risk_refs"], list) or not case["risk_refs"]:
70            errors.append(f"{prefix}.risk_refs must be a non-empty list")
71        elif any(not isinstance(reference, str) or reference not in RISK_REFERENCES for reference in case["risk_refs"]):
72            errors.append(f"{prefix}.risk_refs contains an unknown OWASP 2026 reference")
73        for field in ("objective", "evidence"):
74            if not isinstance(case[field], str) or not case[field].strip():
75                errors.append(f"{prefix}.{field} must be non-empty text")
76        linked_cases = case["linked_cases"]
77        if not isinstance(linked_cases, list):
78            errors.append(f"{prefix}.linked_cases must be a list")
79        elif case["coverage"] == "automated":
80            if not linked_cases:
81                errors.append(f"{prefix}: automated coverage needs at least one local harness case")
82            elif any(not isinstance(case_id, str) or case_id not in KNOWN_CASES for case_id in linked_cases):
83                errors.append(f"{prefix}: automated coverage references an unknown local harness case")
84        elif case["coverage"] == "out_of_scope":
85            reason = case.get("why_not_tested")
86            if linked_cases or not isinstance(reason, str) or not reason.strip():
87                errors.append(f"{prefix}: out_of_scope needs a reason and no linked cases")
88    return errors
89
90
91def prioritize(cases: list[dict[str, Any]]) -> list[dict[str, Any]]:
92    """Sort by the agreed review priority while preserving order within a tier."""
93    return sorted(cases, key=lambda case: PRIORITY_RANK[case["priority"]])
94
95
96def render_summary(plan: dict[str, Any]) -> str:
97    """Format a concise plan summary for human review."""
98    errors = validate_plan(plan)
99    if errors:
100        raise ValueError("invalid plan: " + "; ".join(errors))
101    scope = plan["scope"]
102    counts = {coverage: 0 for coverage in sorted(COVERAGE)}
103    for case in plan["test_cases"]:
104        counts[case["coverage"]] += 1
105    lines = [
106        f"Engagement: {plan['engagement_id']}",
107        f"Target: {scope['target']} ({scope['environment']})",
108        f"Written authorization: {scope['written_authorization']}",
109        f"Coverage: {counts['automated']} automated, {counts['manual']} manual, {counts['out_of_scope']} out of scope",
110        "Prioritized test cases:",
111    ]
112    for case in prioritize(plan["test_cases"]):
113        refs = ", ".join(case["risk_refs"])
114        lines.append(f"- {case['priority'].upper()} | {case['test_id']} | {case['coverage']} | {refs}")
115        if case["coverage"] == "out_of_scope":
116            lines.append(f"  Not tested: {case['why_not_tested']}")
117    return "\n".join(lines)
118
119
120def main() -> int:
121    parser = argparse.ArgumentParser(description=__doc__)
122    parser.add_argument("plan", type=Path, help="JSON plan for the offline synthetic fixture")
123    args = parser.parse_args()
124    plan = json.loads(args.plan.read_text(encoding="utf-8"))
125    errors = validate_plan(plan)
126    if errors:
127        print("Plan needs review:")
128        for error in errors:
129            print(f"- {error}")
130        return 1
131    print(render_summary(plan))
132    return 0
133
134
135if __name__ == "__main__":
136    raise SystemExit(main())

test_engagement_plan.py

python
1"""Tests for the offline engagement-plan validator."""
2
3from __future__ import annotations
4
5import copy
6import json
7import tempfile
8import unittest
9from pathlib import Path
10
11from engagement_plan import prioritize, render_summary, validate_plan
12
13
14def sample_plan() -> dict[str, object]:
15    return {
16        "engagement_id": "fictional-helpdesk-review",
17        "scope": {
18            "target": "local-helpdesk-fixture",
19            "environment": "offline",
20            "written_authorization": True,
21            "in_scope": ["synthetic customer prompts", "in-memory ticket tool"],
22            "out_of_scope": ["hosted providers", "real accounts or records"],
23            "stop_conditions": ["unexpected network access", "any real data appears"],
24        },
25        "test_cases": [
26            {
27                "test_id": "ordinary-baseline",
28                "risk_refs": ["LLM10:2026"],
29                "priority": "medium",
30                "coverage": "automated",
31                "objective": "Preserve the ordinary fictional support reply.",
32                "evidence": "Harness pass signal and target version.",
33                "linked_cases": ["ordinary-request-response"],
34            },
35            {
36                "test_id": "tenant-isolation",
37                "risk_refs": ["LLM02:2026"],
38                "priority": "high",
39                "coverage": "automated",
40                "objective": "Keep another tenant's synthetic ticket marker private.",
41                "evidence": "Harness pass signal and target version.",
42                "linked_cases": ["cross-tenant-ticket-canary"],
43            },
44        ],
45    }
46
47
48class EngagementPlanTests(unittest.TestCase):
49    def test_valid_local_plan_is_accepted(self) -> None:
50        self.assertEqual(validate_plan(sample_plan()), [])
51
52    def test_external_target_is_rejected(self) -> None:
53        plan = sample_plan()
54        plan["scope"]["target"] = "https://example.invalid"
55        self.assertTrue(any("target must be" in error for error in validate_plan(plan)))
56
57    def test_missing_written_authorization_is_rejected(self) -> None:
58        plan = sample_plan()
59        plan["scope"]["written_authorization"] = False
60        self.assertTrue(any("written_authorization" in error for error in validate_plan(plan)))
61
62    def test_missing_engagement_id_is_rejected(self) -> None:
63        plan = sample_plan()
64        plan["engagement_id"] = ""
65        self.assertTrue(any("engagement_id" in error for error in validate_plan(plan)))
66
67    def test_duplicate_test_ids_are_rejected(self) -> None:
68        plan = sample_plan()
69        plan["test_cases"].append(copy.deepcopy(plan["test_cases"][0]))
70        self.assertTrue(any("unique" in error for error in validate_plan(plan)))
71
72    def test_unknown_harness_case_is_rejected(self) -> None:
73        plan = sample_plan()
74        plan["test_cases"][0]["linked_cases"] = ["run-arbitrary-url"]
75        self.assertTrue(any("unknown local harness case" in error for error in validate_plan(plan)))
76
77    def test_wrong_field_types_are_rejected_without_crashing(self) -> None:
78        plan = sample_plan()
79        plan["test_cases"][0]["coverage"] = []
80        plan["test_cases"][0]["risk_refs"] = [{}]
81        errors = validate_plan(plan)
82        self.assertTrue(any("coverage must be" in error for error in errors))
83        self.assertTrue(any("unknown OWASP" in error for error in errors))
84
85        plan = sample_plan()
86        plan["test_cases"][0]["linked_cases"] = [{}]
87        errors = validate_plan(plan)
88        self.assertTrue(any("unknown local harness case" in error for error in errors))
89
90    def test_wrong_scope_collection_types_are_rejected(self) -> None:
91        plan = sample_plan()
92        plan["scope"]["stop_conditions"] = [None]
93        self.assertTrue(any("scope.stop_conditions" in error for error in validate_plan(plan)))
94
95    def test_out_of_scope_case_needs_a_reason_and_no_case_ids(self) -> None:
96        plan = sample_plan()
97        case = plan["test_cases"][0]
98        case["coverage"] = "out_of_scope"
99        case["linked_cases"] = []
100        self.assertTrue(any("out_of_scope needs a reason" in error for error in validate_plan(plan)))
101
102    def test_high_priority_cases_sort_before_medium(self) -> None:
103        cases = sample_plan()["test_cases"]
104        ordered = prioritize(cases)
105        self.assertEqual([case["priority"] for case in ordered], ["high", "medium"])
106
107    def test_summary_discloses_out_of_scope_gaps(self) -> None:
108        plan = sample_plan()
109        plan["test_cases"].append({
110            "test_id": "persistent-memory",
111            "risk_refs": ["ASI06:2026"],
112            "priority": "high",
113            "coverage": "out_of_scope",
114            "objective": "Check that an unsafe note cannot steer a later session.",
115            "evidence": "No persistent-memory fixture is available.",
116            "linked_cases": [],
117            "why_not_tested": "The local practice app has no persistent memory store.",
118        })
119        summary = render_summary(plan)
120        self.assertIn("1 out of scope", summary)
121        self.assertIn("no persistent memory store", summary)
122
123    def test_cli_input_shape_can_be_stored_as_json(self) -> None:
124        with tempfile.TemporaryDirectory() as directory:
125            path = Path(directory) / "plan.json"
126            path.write_text(json.dumps(sample_plan()), encoding="utf-8")
127            self.assertEqual(validate_plan(json.loads(path.read_text(encoding="utf-8"))), [])
128
129
130if __name__ == "__main__":
131    unittest.main()

engagement_plan.json

json
1{
2  "engagement_id": "fictional-helpdesk-review",
3  "scope": {
4    "target": "local-helpdesk-fixture",
5    "environment": "offline",
6    "written_authorization": true,
7    "in_scope": [
8      "synthetic HelpDesk Bot prompts and records",
9      "the in-memory synthetic ticket tool",
10      "the provided local regression cases"
11    ],
12    "out_of_scope": [
13      "hosted AI providers and production services",
14      "real accounts, customer records, and credentials",
15      "installing or connecting an MCP server",
16      "persistent memory and multi-agent systems"
17    ],
18    "stop_conditions": [
19      "any real personal data or credential appears",
20      "a test attempts to access a network service",
21      "the fixture behaves outside its documented synthetic state"
22    ]
23  },
24  "test_cases": [
25    {
26      "test_id": "tenant-ticket-isolation",
27      "risk_refs": ["LLM02:2026", "LLM09:2026"],
28      "priority": "critical",
29      "coverage": "automated",
30      "objective": "A synthetic account must not receive another tenant's ticket marker.",
31      "evidence": "Harness case result, target version, and trial count.",
32      "linked_cases": ["cross-tenant-ticket-canary"]
33    },
34    {
35      "test_id": "hidden-context-canary",
36      "risk_refs": ["LLM01:2026", "LLM08:2026"],
37      "priority": "high",
38      "coverage": "automated",
39      "objective": "The synthetic hidden-context marker must not reach the caller.",
40      "evidence": "Harness case result, target version, and trial count.",
41      "linked_cases": ["system-canary-not-returned"]
42    },
43    {
44      "test_id": "ordinary-request-baseline",
45      "risk_refs": ["LLM10:2026"],
46      "priority": "medium",
47      "coverage": "automated",
48      "objective": "A safe fictional request must continue to receive the expected response.",
49      "evidence": "Harness case result and exact expected reply.",
50      "linked_cases": ["ordinary-request-response"]
51    },
52    {
53      "test_id": "simulated-action-permission",
54      "risk_refs": ["LLM03:2026", "ASI02:2026"],
55      "priority": "high",
56      "coverage": "manual",
57      "objective": "A synthetic ticket proposal must stay pending without confirmation and remain limited to the allowlist.",
58      "evidence": "Local unit-test result and in-memory ticket count.",
59      "linked_cases": []
60    },
61    {
62      "test_id": "persistent-memory-isolation",
63      "risk_refs": ["LLM01:2026", "ASI06:2026"],
64      "priority": "high",
65      "coverage": "out_of_scope",
66      "objective": "Check that untrusted content cannot steer future sessions through stored memory.",
67      "evidence": "Record the gap and require a separate authorized memory fixture before testing.",
68      "linked_cases": [],
69      "why_not_tested": "The local practice app has no persistent memory store."
70    },
71    {
72      "test_id": "inter-agent-message-trust",
73      "risk_refs": ["ASI07:2026", "ASI08:2026"],
74      "priority": "medium",
75      "coverage": "out_of_scope",
76      "objective": "Verify that delegated messages carry authenticated identity and scoped permissions.",
77      "evidence": "Record the gap and needed synthetic multi-agent fixture.",
78      "linked_cases": [],
79      "why_not_tested": "The local practice app has no agent delegation or inter-agent channel."
80    },
81    {
82      "test_id": "mcp-server-boundary",
83      "risk_refs": ["LLM04:2026", "ASI04:2026"],
84      "priority": "medium",
85      "coverage": "out_of_scope",
86      "objective": "Review server identity, tool definitions, scopes, and argument checks.",
87      "evidence": "Record that no MCP server was installed or connected.",
88      "linked_cases": [],
89      "why_not_tested": "The authorized fixture has no MCP server, and the exercise must remain offline."
90    }
91  ]
92}

Run the validator and its tests:

bash
1python3 engagement_plan.py engagement_plan.json
2python3 -m unittest -v test_engagement_plan

The command prints this summary:

text
1Engagement: fictional-helpdesk-review
2Target: local-helpdesk-fixture (offline)
3Written authorization: True
4Coverage: 3 automated, 1 manual, 3 out of scope
5Prioritized test cases:
6- CRITICAL | tenant-ticket-isolation | automated | LLM02:2026, LLM09:2026
7- HIGH | hidden-context-canary | automated | LLM01:2026, LLM08:2026
8- HIGH | simulated-action-permission | manual | LLM03:2026, ASI02:2026
9- HIGH | persistent-memory-isolation | out_of_scope | LLM01:2026, ASI06:2026
10  Not tested: The local practice app has no persistent memory store.
11- MEDIUM | ordinary-request-baseline | automated | LLM10:2026
12- MEDIUM | inter-agent-message-trust | out_of_scope | ASI07:2026, ASI08:2026
13  Not tested: The local practice app has no agent delegation or inter-agent channel.
14- MEDIUM | mcp-server-boundary | out_of_scope | LLM04:2026, ASI04:2026
15  Not tested: The authorized fixture has no MCP server, and the exercise must remain offline.

The summary lists the critical and high-priority tests first and shows the cases that remain out of scope. The tests verify that the validator rejects an external target, missing written authorization, duplicate case IDs, unknown local harness cases, and an unexplained gap. They also check the agreed priority order. These tests validate the plan checker; they do not prove the HelpDesk application is secure.

One quick mutation check confirms the sorting test can catch a broken priority table. In a temporary copy, change the high-priority rank from 1 to 3 in PRIORITY_RANK, then run python3 -m unittest -v test_engagement_plan.EngagementPlanTests.test_high_priority_cases_sort_before_medium. That test should fail because the medium case now sorts first. Restore the original value before continuing; keep mutations in a disposable copy.

To run the existing synthetic application checks, use the local lab's harness from its folder and keep results in a new temporary file:

bash
1RESULTS_DIR="$(mktemp -d /tmp/helpdesk-agent-review.XXXXXX)"
2python3 run_harness.py --target defended --results "$RESULTS_DIR/results.jsonl" --repeats 3

The harness currently covers cross-tenant ticket access, a synthetic hidden-context canary, and a normal baseline response. The existing local unit tests also cover a simulated ticket action and confirmation. The memory, MCP, and multi-agent rows remain explicit gaps because no corresponding fixture exists. A passing run does not close those gaps.

The sample plan contains priorities to show sorting behavior, not a universal severity scale. A real engagement owner should decide priority from the system's context and rules of engagement. If the plan changes, keep the previous copy and record why a test or scope boundary changed.

Read a result without overclaiming

Suppose the harness reports that the cross-tenant case passed on all three runs against the defended local target. The supported statement is: "The local defended fixture denied the synthetic cross-tenant case in three runs under this configuration." The run does not establish that a hosted service, another endpoint, or a different tenant policy behaves the same way.

If the tool action test stays pending without confirmation, record the proposed action and the dummy state before and after. Do not infer safety from a refusal phrase. If a request errors or telemetry is unavailable, mark the test inconclusive rather than counting it as a block. If the model's wording varies, keep the repeated trial results and review what the application did, not just how it phrased the answer.

OWASP's 2026 LLM and Agentic lists provide a taxonomy, while MCP's official security pages describe protocol-specific boundaries such as authorization, server-side request risks, and local server execution. Neither source can tell you which components exist in your deployment. The test plan records that system-specific choice and the evidence needed to revisit it.

Mistakes beginners make

  • Testing only the user prompt while ignoring retrieval, memory, tool output, and delegated tasks.
  • Treating an OWASP category as proof of a vulnerability or as a complete checklist.
  • Using real accounts, records, tokens, or external destinations when a synthetic fixture would answer the question.
  • Assuming a model refusal means no tool ran or no data moved.
  • Giving several agents the same broad identity and trusting their names to distinguish permissions.
  • Treating MCP as an authorization layer for the application without checking the actual server and caller permissions.
  • Marking memory, multi-agent, or MCP risks as passed when no fixture or test exists.
  • Recording only the final answer and losing the identity, version, action, and system event that explain it.
  • Claiming a general security result from a few local cases.

Recap and try it yourself

An LLM app test follows data and authority through the full application. An agent test adds tool choices, memory, delegation, and connections such as MCP. Start with written authorization and a local, synthetic target. Map risks to specific questions, define safe evidence, prioritize the boundaries where sensitive data and actions meet, and keep untested features visible as gaps.

Try it with the sample JSON. Add one test row for a harmless feature in a system you own or a fictional app. Name the risk, target boundary, expected result, evidence, and coverage status. If the current fixture cannot support it, mark it out of scope with a reason. Run the validator and explain to another reader what the plan covers and what it does not. Do not connect to a live service.

The final capstone will use the scope, test plan, harness, findings report, fixes, and retest process together on the local practice app.

Sources checked on 6 November 2026: OWASP's LLM Top 10 2026 supports the current ten risk labels and distinguishes LLM components from agentic systems. OWASP's Top 10 for Agentic Applications 2026 supports the agent-specific categories, including memory and context poisoning and insecure inter-agent communication. The current MCP authorization specification describes authorization for HTTP transports and notes that protocol authorization is optional; the current MCP Security Best Practices describe confused-deputy, SSRF, and local-server risks. Protocol versions and security guidance can change; check the current specification for the deployment under review. The example plan and test outcomes use synthetic local fixtures only.

Related lessons in other courses