Back in June I shipped P2 Threat Model Generator — a Python tool that reads Docker Compose, OpenAPI, and Kubernetes manifests, runs STRIDE analysis, scores threats, and spits out HTML + JSON reports with MITRE ATT&CK mappings.
It works. It's boring. That's fine — the boring parts (rule engine, scorer, reporters) are the parts you actually want deterministic.
The part that isn't boring is getting the input in the first place. Real threat modelling conversations don't start with a YAML file. They start with:
"So what does this service actually do?"
So I added an agentic mode.
I wanted to keep every single deterministic piece — the STRIDEScorer
, the compliance flag matcher, the HTML reporter — untouched.
All the security reasoning stays in code. The LLM only does one job: talk to the user and figure out what to feed into ApplicationDescriptor.
That means the agent isn't "generating threats" or "reasoning about security."
It's an interviewer with a schema.
Both my SAST/DAST triage tool (P1) and this one now run on the same in-house harness — agent-core
.
Small library, three primitives:
from agent_core import Agent, tool, ToolRegistry
from agent_core.models import ToolRisk, ExecutionPolicy
@tool
def example():
...
ToolRegistry(...)
Agent(...)
@tool decorates a function. ToolRegistry bundles them. Agent runs the model and handles tool calls.
Policies control which risk tiers are permitted (NONE, FILESYSTEM, EXECUTION). Providers are swappable — Claude for interactive work, Ollama for offline runs.
The tool surface
Twelve @tool functions do everything the agent can do. The star of the show is ask_user:
description=(
"Ask the human user a single question and return "
"the answer as a string. Ask ONE question at a time; "
"wait for the answer; then decide the next question."
),
risk=ToolRisk.NONE,
)
def ask_user(question: str) -> str:
print(f"\n🤖 {question}")
answer = input("👤 ").strip()
return _ok(answer=answer)
The rest split into three groups:
Build the descriptor — start_app, set_compliance, add_component, add_data_flow
Inspect state — list_components, list_flows, get_app
Run the pipeline — run_stride_analysis, enrich_threats, score_threats, generate_report
Every tool takes a session_id and threads state through a module-level store:
class SessionState(TypedDict):
descriptor: ApplicationDescriptor | None
threats: list[Threat]
model: ThreatModel | None
_STORE: dict[str, SessionState] = {}
Not thread-safe. Doesn't need to be. This is a single-process CLI.
The system prompt
You are a senior threat-modelling analyst using STRIDE. Your job is to
interview a human user about their application, extract components and
data flows, and produce a threat model report.
Workflow:
Rules:
The important line is:
"Do not invent user input — always ask."
Without it the model happily hallucinates an entire architecture and skips straight to the report. ask_user returning stdin is what keeps it honest.
Why this pattern beats "extract from prose"
The obvious alternative: user writes a paragraph describing their app, agent parses it, calls the builder tools, done.
I tried that first. Two problems:
If the paragraph doesn't mention rate limiting, the agent picks a plausible default and moves on.
You get a threat model with has_rate_limiting=True for a component that has no such thing.
The user gave you their description; you can't ask them:
"Wait, does this API actually authenticate?"
without breaking the single-shot contract.
Turn-by-turn interviewing via ask_user fixes both. The model can ask when it needs information, and it can ask follow-ups whenever it hits a gap.
Trade-off: latency.
A full interview is 20–40 tool calls. With Claude, it takes around 2 minutes. Ollama with a 70B local model takes 8–10 minutes.
Testing
Twenty-six tests, all mocking the provider so nothing hits a real LLM in CI:
def test_ask_user_returns_input() -> None:
with patch("builtins.input", return_value="my-app"):
result = ask_user(question="What is the app name?")
data = json.loads(result)
assert data["answer"] == "my-app"
The smoke test verifies the whole pipeline wires up without touching Anthropic or Ollama:
def test_full_pipeline_smoke(tmp_path) -> None:
provider = MagicMock()
fake_result = AgentResult(
output="Threat model generated: 12 threats, 2 critical",
stop_reason=StopReason.DONE,
iterations=15,
tool_calls=[],
)
agent = ThreatModelAgent(
provider=provider,
max_iterations=5,
)
agent._build_agent = MagicMock()
agent._build_agent.return_value.run.return_value = fake_result
result = agent.run(
session_id="smoke1",
describe="e-commerce API with Postgres and Redis",
output_dir=str(tmp_path),
)
assert result.stop_reason == StopReason.DONE
Live tests are manual and off the critical path.
CLI
Interactive Claude interview
python p2_threat_model.py --agentic --provider claude
Seeded with a paragraph so the agent has context before asking questions
python p2_threat_model.py --agentic --provider claude \
--describe "FastAPI backend, Postgres, public login, stores customer data"
Local / free / slow
python p2_threat_model.py --agentic --provider ollama
Reports land in:
./output/-threat-model.{json,html}
What I'd change
Persist session state between runs. Right now the interview is single-shot. Interrupting halfway loses the descriptor. A pickle or JSON dump per session would fix that.
Tool-call replay for debugging. The agent-core tracer captures every call but there's no CLI to replay a session offline.
A dry-run mode. Print the questions the agent would ask so you can eyeball the tool-call graph.
Repo
github.com/PyHackSecGP/p2-threat-model-generator (branch feat/...)
Same treatment coming for P3 (log anomaly detector) and P4.
The whole point of building agent-core was that once you have the harness, adding an agentic mode to any deterministic tool is a week's work.
If you're building agentic security tools and want to trade notes, I'm at greenbladesec.com.