DEV Community

Cover image for I gave my STRIDE threat modelling tool an agentic interview mode
greenbladesec
greenbladesec

Posted on

I gave my STRIDE threat modelling tool an agentic interview mode

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.

The design constraint

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.

The harness

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(...)
Enter fullscreen mode Exit fullscreen mode

Yep. Here it is as clean, copy/paste-ready Markdown. I’ve made sure every code block opens and closes properly.

@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:

@tool(
    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)
Enter fullscreen mode Exit fullscreen mode

The rest split into three groups:

  1. Build the descriptorstart_app, set_compliance, add_component, add_data_flow
  2. Inspect statelist_components, list_flows, get_app
  3. Run the pipelinerun_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] = {}
Enter fullscreen mode Exit fullscreen mode

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:

1. Use ask_user to ask their application name, purpose, environment,
   and whether it is internet-facing.
2. Call start_app with the answers.
3. Ask about compliance requirements. If any, call set_compliance.
4. Iteratively ask about components. Call add_component after each answer.
5. Iteratively ask about data flows. Call add_data_flow.
6. When done, call run_stride_analysis.
7. Call score_threats.
8. Call generate_report with format="all".
9. Summarize: total threats, severity breakdown, top 3 critical/high.

Rules:

- Ask ONE question per ask_user call. Never batch questions.
- Always pass the session_id you were given, unchanged.
- If a tool returns ok=false, read the error and correct the action.
- Do not invent user input — always ask.
Enter fullscreen mode Exit fullscreen mode

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:

1. Silent hallucination

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.

2. No follow-up loop

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"
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Live tests are manual and off the critical path.

Interactive Claude interview

python p2_threat_model.py --agentic --provider claude
Enter fullscreen mode Exit fullscreen mode

Give the agent context before it starts asking questions:

python p2_threat_model.py --agentic --provider claude \
    --describe "FastAPI backend, Postgres, public login, stores customer data"
Enter fullscreen mode Exit fullscreen mode

Local / free / slow

python p2_threat_model.py --agentic --provider ollama
Enter fullscreen mode Exit fullscreen mode

Reports are generated in:

./output/<slug>-threat-model.{json,html}
Enter fullscreen mode Exit fullscreen mode

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 Repository (feat/... branch)

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

Top comments (0)