stride-gpt 0.16.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. stride_gpt/__init__.py +1 -0
  2. stride_gpt/agent/__init__.py +1 -0
  3. stride_gpt/agent/context.py +167 -0
  4. stride_gpt/agent/loop.py +617 -0
  5. stride_gpt/agent/planner.py +188 -0
  6. stride_gpt/agent/progress.py +168 -0
  7. stride_gpt/agent/quick.py +198 -0
  8. stride_gpt/agent/report.py +592 -0
  9. stride_gpt/agent/tools.py +318 -0
  10. stride_gpt/cli.py +977 -0
  11. stride_gpt/config.py +580 -0
  12. stride_gpt/core/__init__.py +3 -0
  13. stride_gpt/core/attack_tree.py +222 -0
  14. stride_gpt/core/dread.py +141 -0
  15. stride_gpt/core/json_extract.py +47 -0
  16. stride_gpt/core/llm.py +349 -0
  17. stride_gpt/core/mitigations.py +39 -0
  18. stride_gpt/core/prompts/__init__.py +41 -0
  19. stride_gpt/core/prompts/builder.py +424 -0
  20. stride_gpt/core/prompts/threat_model/__init__.py +5 -0
  21. stride_gpt/core/prompts/threat_model/agentic.md +129 -0
  22. stride_gpt/core/prompts/threat_model/base.md +39 -0
  23. stride_gpt/core/prompts/threat_model/genai.md +70 -0
  24. stride_gpt/core/prompts/threat_model/insider_threat.md +120 -0
  25. stride_gpt/core/prompts/threat_model/quick_base.md +39 -0
  26. stride_gpt/core/prompts/variants.py +210 -0
  27. stride_gpt/core/report_utils.py +87 -0
  28. stride_gpt/core/schemas.py +115 -0
  29. stride_gpt/core/test_cases.py +46 -0
  30. stride_gpt/core/threat_model.py +157 -0
  31. stride_gpt/models.py +355 -0
  32. stride_gpt/prompt.py +180 -0
  33. stride_gpt-0.16.0.dist-info/METADATA +489 -0
  34. stride_gpt-0.16.0.dist-info/RECORD +38 -0
  35. stride_gpt-0.16.0.dist-info/WHEEL +5 -0
  36. stride_gpt-0.16.0.dist-info/entry_points.txt +2 -0
  37. stride_gpt-0.16.0.dist-info/licenses/LICENSE +21 -0
  38. stride_gpt-0.16.0.dist-info/top_level.txt +1 -0
stride_gpt/__init__.py ADDED
@@ -0,0 +1 @@
1
+ """STRIDE-GPT: AI-powered threat modeling using the STRIDE methodology."""
@@ -0,0 +1 @@
1
+ """Agentic threat modeling — codebase analysis via LLM tool-calling."""
@@ -0,0 +1,167 @@
1
+ """Context management — token counting and message compression."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from enum import Enum
6
+
7
+ import httpx
8
+ import litellm
9
+
10
+ from stride_gpt.core.llm import call_llm
11
+ from stride_gpt.core.schemas import LLMConfig
12
+ from stride_gpt.models import get_model
13
+
14
+ # Default context limits per model family (input tokens).
15
+ # Conservative — leaves room for output.
16
+ DEFAULT_LIMITS: dict[str, int] = {
17
+ "claude": 180_000,
18
+ "gpt": 120_000,
19
+ "gemini": 900_000,
20
+ "llama": 120_000,
21
+ "mistral": 28_000,
22
+ "default": 28_000,
23
+ }
24
+
25
+ COMPRESSION_THRESHOLD = 0.80 # Compress when at 80% of limit
26
+ KEEP_RECENT = 6 # Number of recent messages to keep uncompressed
27
+
28
+
29
+ class TokenBudgetSource(Enum):
30
+ """How the token budget was determined."""
31
+
32
+ QUERIED = "queried" # Got the actual configured value from the provider API
33
+ INFERRED = "inferred" # Guessed from model name
34
+ EXPLICIT = "explicit" # Caller provided context_window directly
35
+
36
+
37
+ class ContextManager:
38
+ """Track token usage and compress messages when approaching the context limit."""
39
+
40
+ def __init__(self, config: LLMConfig, context_window: int | None = None):
41
+ self.model = config.model_name
42
+ if context_window:
43
+ self.context_window = context_window
44
+ self.budget_source = TokenBudgetSource.EXPLICIT
45
+ else:
46
+ self.context_window, self.budget_source = self._resolve_limit(config)
47
+
48
+ def count_tokens(self, messages: list[dict]) -> int:
49
+ """Count tokens in a message list using LiteLLM's counter."""
50
+ try:
51
+ return litellm.token_counter(model=self.model, messages=messages)
52
+ except Exception:
53
+ # Rough fallback: ~4 chars per token
54
+ total_chars = sum(len(str(m.get("content", ""))) for m in messages)
55
+ return total_chars // 4
56
+
57
+ def needs_compression(self, messages: list[dict]) -> bool:
58
+ """Check if messages are approaching the context limit."""
59
+ tokens = self.count_tokens(messages)
60
+ return tokens > int(self.context_window * COMPRESSION_THRESHOLD)
61
+
62
+ def compress(self, config: LLMConfig, messages: list[dict]) -> list[dict]:
63
+ """Compress older messages by summarizing tool results.
64
+
65
+ Keeps the system prompt, the analysis plan context, and recent messages
66
+ intact. Summarizes everything in between into a condensed findings block.
67
+
68
+ `config` is the LLM used to *perform* the summarization (an architect-
69
+ tier reasoning task). The context window the ContextManager tracks is
70
+ the worker's — that's the model whose conversation we're compressing.
71
+ """
72
+ if len(messages) <= KEEP_RECENT + 2:
73
+ return messages # Nothing worth compressing
74
+
75
+ # Split: system message(s) at the start, recent messages, middle to compress
76
+ system_msgs = []
77
+ rest = []
78
+ for msg in messages:
79
+ if msg["role"] == "system" and not rest:
80
+ system_msgs.append(msg)
81
+ else:
82
+ rest.append(msg)
83
+
84
+ if len(rest) <= KEEP_RECENT:
85
+ return messages
86
+
87
+ to_compress = rest[:-KEEP_RECENT]
88
+ to_keep = rest[-KEEP_RECENT:]
89
+
90
+ # Build summary of the compressed section
91
+ compressed_text = self._summarize(config, to_compress)
92
+ summary_msg = {
93
+ "role": "user",
94
+ "content": f"[Previous exploration summary]\n{compressed_text}",
95
+ }
96
+
97
+ return system_msgs + [summary_msg] + to_keep
98
+
99
+ def _summarize(self, config: LLMConfig, messages: list[dict]) -> str:
100
+ """Summarize a list of messages into key findings."""
101
+ # Build a text representation of the messages
102
+ parts: list[str] = []
103
+ for msg in messages:
104
+ role = msg.get("role", "unknown")
105
+ content = str(msg.get("content", ""))[:2000] # Truncate long entries
106
+ parts.append(f"[{role}] {content}")
107
+
108
+ conversation = "\n---\n".join(parts)
109
+
110
+ summary_messages = [
111
+ {
112
+ "role": "system",
113
+ "content": "Summarize the following agent exploration into a concise list of key findings. Focus on: files examined, security-relevant patterns found, architectural observations, and any threats identified. Be brief but preserve important details.",
114
+ },
115
+ {"role": "user", "content": conversation},
116
+ ]
117
+
118
+ response = call_llm(config, summary_messages)
119
+ return response.content
120
+
121
+ @staticmethod
122
+ def _resolve_limit(config: LLMConfig) -> tuple[int, TokenBudgetSource]:
123
+ """Resolve the context token limit for the model.
124
+
125
+ Queries LM Studio for the actual loaded context length.
126
+ Otherwise reads the per-model context window from the registry,
127
+ falling back to name-based inference for unregistered models.
128
+ """
129
+ if config.provider == "LM Studio Server" and config.api_base:
130
+ result = _query_lm_studio_context(config.api_base, config.model_name)
131
+ if result:
132
+ return result, TokenBudgetSource.QUERIED
133
+
134
+ registered = get_model(config.provider, config.model_name)
135
+ if registered is not None:
136
+ return registered.max_tokens, TokenBudgetSource.QUERIED
137
+
138
+ return _infer_limit_from_name(config.model_name), TokenBudgetSource.INFERRED
139
+
140
+
141
+ def _infer_limit_from_name(model: str) -> int:
142
+ """Infer context limit from model name keywords."""
143
+ lower = model.lower()
144
+ for key, limit in DEFAULT_LIMITS.items():
145
+ if key in lower:
146
+ return limit
147
+ return DEFAULT_LIMITS["default"]
148
+
149
+
150
+ def _query_lm_studio_context(api_base: str, model_name: str) -> int | None:
151
+ """Query LM Studio's native API for the model's loaded context length."""
152
+ try:
153
+ url = api_base.rstrip("/") + "/api/v1/models"
154
+ resp = httpx.get(url, timeout=5)
155
+ resp.raise_for_status()
156
+ for model in resp.json().get("models", []):
157
+ if model.get("key") != model_name:
158
+ continue
159
+ loaded_instances = model.get("loaded_instances", [])
160
+ if not loaded_instances:
161
+ continue
162
+ loaded_ctx = loaded_instances[0].get("config", {}).get("context_length", 0)
163
+ if loaded_ctx > 0:
164
+ return loaded_ctx
165
+ except (httpx.HTTPError, KeyError, TypeError, ValueError):
166
+ pass
167
+ return None