{"slug": "i-gave-my-stride-threat-modelling-tool-an-agentic-interview-mode", "title": "I gave my STRIDE threat modelling tool an agentic interview mode", "summary": "A developer added an agentic interview mode to their STRIDE threat modeling tool, P2 Threat Model Generator, which reads Docker Compose, OpenAPI, and Kubernetes manifests. The new mode uses an LLM to interview users turn-by-turn, extracting application details into a structured descriptor while keeping all security reasoning deterministic in code. The tool is built on an in-house agent harness called agent-core, with a tool surface of twelve functions, and supports multiple LLM providers including Claude and Ollama.", "body_md": "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.\n\nIt works. It's boring. That's fine — the boring parts (rule engine, scorer, reporters) are the parts you actually want deterministic.\n\nThe 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:\n\n\"So what does this service actually do?\"\n\nSo I added an **agentic mode**.\n\nI wanted to keep every single deterministic piece — the `STRIDEScorer`\n\n, the compliance flag matcher, the HTML reporter — untouched.\n\nAll the security reasoning stays in code. The LLM only does one job: **talk to the user and figure out what to feed into ApplicationDescriptor.**\n\nThat means the agent isn't \"generating threats\" or \"reasoning about security.\"\n\nIt's an **interviewer with a schema**.\n\nBoth my SAST/DAST triage tool (P1) and this one now run on the same in-house harness — `agent-core`\n\n.\n\nSmall library, three primitives:\n\n``` python\nfrom agent_core import Agent, tool, ToolRegistry\nfrom agent_core.models import ToolRisk, ExecutionPolicy\n\n@tool\ndef example():\n    ...\n\nToolRegistry(...)\nAgent(...)\n```\n\n[@tool](https://dev.to/tool) decorates a function. ToolRegistry bundles them. Agent runs the model and handles tool calls.\n\nPolicies control which risk tiers are permitted (NONE, FILESYSTEM, EXECUTION). Providers are swappable — Claude for interactive work, Ollama for offline runs.\n\nThe tool surface\n\nTwelve [@tool](https://dev.to/tool) functions do everything the agent can do. The star of the show is ask_user:\n\n[@tool](https://dev.to/tool)(\n\ndescription=(\n\n\"Ask the human user a single question and return \"\n\n\"the answer as a string. Ask ONE question at a time; \"\n\n\"wait for the answer; then decide the next question.\"\n\n),\n\nrisk=ToolRisk.NONE,\n\n)\n\ndef ask_user(question: str) -> str:\n\nprint(f\"\\n🤖 {question}\")\n\nanswer = input(\"👤 \").strip()\n\nreturn _ok(answer=answer)\n\nThe rest split into three groups:\n\nBuild the descriptor — start_app, set_compliance, add_component, add_data_flow\n\nInspect state — list_components, list_flows, get_app\n\nRun the pipeline — run_stride_analysis, enrich_threats, score_threats, generate_report\n\nEvery tool takes a session_id and threads state through a module-level store:\n\nclass SessionState(TypedDict):\n\ndescriptor: ApplicationDescriptor | None\n\nthreats: list[Threat]\n\nmodel: ThreatModel | None\n\n_STORE: dict[str, SessionState] = {}\n\nNot thread-safe. Doesn't need to be. This is a single-process CLI.\n\nThe system prompt\n\nYou are a senior threat-modelling analyst using STRIDE. Your job is to\n\ninterview a human user about their application, extract components and\n\ndata flows, and produce a threat model report.\n\nWorkflow:\n\nRules:\n\nThe important line is:\n\n\"Do not invent user input — always ask.\"\n\nWithout it the model happily hallucinates an entire architecture and skips straight to the report. ask_user returning stdin is what keeps it honest.\n\nWhy this pattern beats \"extract from prose\"\n\nThe obvious alternative: user writes a paragraph describing their app, agent parses it, calls the builder tools, done.\n\nI tried that first. Two problems:\n\nIf the paragraph doesn't mention rate limiting, the agent picks a plausible default and moves on.\n\nYou get a threat model with has_rate_limiting=True for a component that has no such thing.\n\nThe user gave you their description; you can't ask them:\n\n\"Wait, does this API actually authenticate?\"\n\nwithout breaking the single-shot contract.\n\nTurn-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.\n\nTrade-off: latency.\n\nA full interview is 20–40 tool calls. With Claude, it takes around 2 minutes. Ollama with a 70B local model takes 8–10 minutes.\n\nTesting\n\nTwenty-six tests, all mocking the provider so nothing hits a real LLM in CI:\n\ndef test_ask_user_returns_input() -> None:\n\nwith patch(\"builtins.input\", return_value=\"my-app\"):\n\nresult = ask_user(question=\"What is the app name?\")\n\n```\ndata = json.loads(result)\n\nassert data[\"answer\"] == \"my-app\"\n```\n\nThe smoke test verifies the whole pipeline wires up without touching Anthropic or Ollama:\n\ndef test_full_pipeline_smoke(tmp_path) -> None:\n\nprovider = MagicMock()\n\n```\nfake_result = AgentResult(\n    output=\"Threat model generated: 12 threats, 2 critical\",\n    stop_reason=StopReason.DONE,\n    iterations=15,\n    tool_calls=[],\n)\n\nagent = ThreatModelAgent(\n    provider=provider,\n    max_iterations=5,\n)\n\nagent._build_agent = MagicMock()\nagent._build_agent.return_value.run.return_value = fake_result\n\nresult = agent.run(\n    session_id=\"smoke1\",\n    describe=\"e-commerce API with Postgres and Redis\",\n    output_dir=str(tmp_path),\n)\n\nassert result.stop_reason == StopReason.DONE\n```\n\nLive tests are manual and off the critical path.\n\nCLI\n\nInteractive Claude interview\n\npython p2_threat_model.py --agentic --provider claude\n\nSeeded with a paragraph so the agent has context before asking questions\n\npython p2_threat_model.py --agentic --provider claude \\\n\n--describe \"FastAPI backend, Postgres, public login, stores customer data\"\n\nLocal / free / slow\n\npython p2_threat_model.py --agentic --provider ollama\n\nReports land in:\n\n./output/-threat-model.{json,html}\n\nWhat I'd change\n\nPersist 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.\n\nTool-call replay for debugging. The agent-core tracer captures every call but there's no CLI to replay a session offline.\n\nA dry-run mode. Print the questions the agent would ask so you can eyeball the tool-call graph.\n\nRepo\n\ngithub.com/PyHackSecGP/p2-threat-model-generator (branch feat/...)\n\nSame treatment coming for P3 (log anomaly detector) and P4.\n\nThe 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.\n\nIf you're building agentic security tools and want to trade notes, I'm at greenbladesec.com.", "url": "https://wpnews.pro/news/i-gave-my-stride-threat-modelling-tool-an-agentic-interview-mode", "canonical_source": "https://dev.to/greenbladesec/i-gave-my-stride-threat-modelling-tool-an-agentic-interview-mode-1dao", "published_at": "2026-09-02 17:02:55+00:00", "updated_at": "2026-09-02 17:24:04.168607+00:00", "lang": "en", "topics": ["ai-agents", "developer-tools", "ai-tools", "artificial-intelligence"], "entities": ["P2 Threat Model Generator", "agent-core", "STRIDE", "Claude", "Ollama", "MITRE ATT&CK"], "alternates": {"html": "https://wpnews.pro/news/i-gave-my-stride-threat-modelling-tool-an-agentic-interview-mode", "markdown": "https://wpnews.pro/news/i-gave-my-stride-threat-modelling-tool-an-agentic-interview-mode.md", "text": "https://wpnews.pro/news/i-gave-my-stride-threat-modelling-tool-an-agentic-interview-mode.txt", "jsonld": "https://wpnews.pro/news/i-gave-my-stride-threat-modelling-tool-an-agentic-interview-mode.jsonld"}}