atris 3.2.0 → 3.11.0

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 (55) hide show
  1. package/GETTING_STARTED.md +65 -131
  2. package/README.md +18 -2
  3. package/atris/GETTING_STARTED.md +65 -131
  4. package/atris/PERSONA.md +5 -1
  5. package/atris/atris.md +122 -153
  6. package/atris/skills/aeo/SKILL.md +117 -0
  7. package/atris/skills/atris/SKILL.md +49 -25
  8. package/atris/skills/create-member/SKILL.md +29 -9
  9. package/atris/skills/endgame/SKILL.md +9 -0
  10. package/atris/skills/research-search/SKILL.md +167 -0
  11. package/atris/skills/research-search/arxiv_search.py +157 -0
  12. package/atris/skills/research-search/program.md +48 -0
  13. package/atris/skills/research-search/results.tsv +6 -0
  14. package/atris/skills/research-search/scholar_search.py +154 -0
  15. package/atris/skills/tidy/SKILL.md +36 -21
  16. package/atris/team/_template/MEMBER.md +2 -0
  17. package/atris/team/validator/MEMBER.md +35 -1
  18. package/atris.md +118 -178
  19. package/bin/atris.js +46 -12
  20. package/cli/__pycache__/atris_code.cpython-314.pyc +0 -0
  21. package/cli/__pycache__/runtime_guard.cpython-312.pyc +0 -0
  22. package/cli/__pycache__/runtime_guard.cpython-314.pyc +0 -0
  23. package/cli/atris_code.py +889 -0
  24. package/cli/runtime_guard.py +693 -0
  25. package/commands/align.js +16 -0
  26. package/commands/app.js +316 -0
  27. package/commands/autopilot.js +863 -23
  28. package/commands/brainstorm.js +7 -5
  29. package/commands/business.js +677 -2
  30. package/commands/clean.js +19 -3
  31. package/commands/computer.js +2022 -43
  32. package/commands/context-sync.js +5 -0
  33. package/commands/integrations.js +14 -9
  34. package/commands/lifecycle.js +12 -0
  35. package/commands/plugin.js +24 -0
  36. package/commands/pull.js +86 -11
  37. package/commands/push.js +153 -9
  38. package/commands/serve.js +1 -0
  39. package/commands/sync.js +272 -76
  40. package/commands/verify.js +50 -1
  41. package/commands/wiki.js +27 -2
  42. package/commands/workflow.js +24 -9
  43. package/lib/file-ops.js +13 -1
  44. package/lib/journal.js +23 -0
  45. package/lib/manifest.js +3 -0
  46. package/lib/scorecard.js +42 -4
  47. package/lib/sync-telemetry.js +59 -0
  48. package/lib/todo.js +6 -0
  49. package/lib/wiki.js +150 -6
  50. package/lib/workspace-safety.js +87 -0
  51. package/package.json +2 -1
  52. package/utils/api.js +19 -0
  53. package/utils/auth.js +25 -1
  54. package/utils/config.js +24 -0
  55. package/utils/update-check.js +16 -0
@@ -0,0 +1,693 @@
1
+ """Atris Runtime Security Guard
2
+
3
+ The defense layer that makes us THE security company.
4
+
5
+ Not scanning. Not auditing. GUARDING.
6
+
7
+ This module provides:
8
+ 1. Real-time tool call interception
9
+ 2. Input sanitization and attack detection
10
+ 3. Anomaly detection for agent behavior
11
+ 4. Security event logging and alerting
12
+ 5. Auto-blocking of malicious requests
13
+
14
+ Drop this into any AI agent system for instant protection.
15
+ """
16
+
17
+ import re
18
+ import time
19
+ import hashlib
20
+ import logging
21
+ from typing import Dict, Any, List, Optional, Set, Callable
22
+ from dataclasses import dataclass, field
23
+ from enum import Enum
24
+ from collections import defaultdict, deque
25
+
26
+ logger = logging.getLogger(__name__)
27
+
28
+
29
+ class ThreatLevel(Enum):
30
+ """Threat severity levels."""
31
+ NONE = 0
32
+ LOW = 1
33
+ MEDIUM = 2
34
+ HIGH = 3
35
+ CRITICAL = 4
36
+
37
+
38
+ class ActionType(Enum):
39
+ """What to do when a threat is detected."""
40
+ ALLOW = "allow"
41
+ LOG = "log"
42
+ WARN = "warn"
43
+ BLOCK = "block"
44
+ QUARANTINE = "quarantine"
45
+
46
+
47
+ @dataclass
48
+ class SecurityEvent:
49
+ """A security event detected by the guard."""
50
+ timestamp: float
51
+ threat_level: ThreatLevel
52
+ event_type: str
53
+ description: str
54
+ agent_id: Optional[str] = None
55
+ user_id: Optional[str] = None
56
+ tool_name: Optional[str] = None
57
+ input_hash: Optional[str] = None
58
+ action_taken: ActionType = ActionType.LOG
59
+ metadata: Dict[str, Any] = field(default_factory=dict)
60
+
61
+
62
+ @dataclass
63
+ class GuardConfig:
64
+ """Configuration for the runtime guard."""
65
+ # Blocking thresholds
66
+ max_requests_per_minute: int = 100
67
+ max_failed_auth_per_minute: int = 5
68
+ max_tool_calls_per_minute: int = 50
69
+
70
+ # Sensitive patterns to block
71
+ block_shell_injection: bool = True
72
+ block_path_traversal: bool = True
73
+ block_prompt_injection: bool = True
74
+ block_sql_injection: bool = True
75
+ block_credential_leak: bool = True
76
+
77
+ # Logging
78
+ log_all_tool_calls: bool = True
79
+ log_blocked_requests: bool = True
80
+
81
+ # Auto-response
82
+ auto_block_after_violations: int = 3
83
+ quarantine_duration_seconds: int = 3600
84
+
85
+ # Memory bounds (prevents RAM leaks in long-running processes)
86
+ max_events: int = 10_000
87
+ sweep_interval_seconds: int = 300 # sweep stale entries every 5 min
88
+ stale_key_ttl_seconds: int = 3600 # remove inactive keys after 1 hour
89
+
90
+
91
+ class RuntimeGuard:
92
+ """
93
+ The Atris Runtime Security Guard.
94
+
95
+ This is what makes us THE defense company.
96
+ Drop it into any agent system for instant protection.
97
+
98
+ Usage:
99
+ guard = RuntimeGuard()
100
+
101
+ # Before executing any tool
102
+ result = guard.check_tool_call(
103
+ agent_id="agent-123",
104
+ user_id="user-456",
105
+ tool_name="execute_code",
106
+ tool_input={"code": user_input}
107
+ )
108
+
109
+ if result.action == ActionType.BLOCK:
110
+ raise SecurityException(result.reason)
111
+ """
112
+
113
+ # Dangerous tool names that always require extra scrutiny
114
+ DANGEROUS_TOOLS: Set[str] = {
115
+ "execute_code", "run_bash", "shell", "eval", "exec",
116
+ "delete_file", "remove", "rm", "rmdir",
117
+ "modify_config", "write_file", "overwrite",
118
+ "access_secrets", "get_credentials", "api_key",
119
+ "send_email", "post_message", "webhook",
120
+ "database_query", "sql", "execute_sql",
121
+ }
122
+
123
+ # Patterns that indicate shell injection attempts
124
+ SHELL_INJECTION_PATTERNS: List[re.Pattern] = [
125
+ # P3: extended dangerous-command list to include post-exploit probes
126
+ re.compile(r';\s*(?:rm|cat|curl|wget|nc|bash|sh|python|perl|ruby|id|whoami|uname|hostname|env|printenv)', re.I),
127
+ re.compile(r'\|\s*(?:sh|bash|zsh|python|perl|ruby|nc)', re.I),
128
+ re.compile(r'\$\([^)]+\)'), # Command substitution
129
+ re.compile(r'`[^`]+`'), # Backtick command substitution
130
+ # P3: extended && and || to include post-exploit + nc
131
+ re.compile(r'&&\s*(?:rm|cat|curl|wget|nc|id|whoami|uname|bash)', re.I),
132
+ re.compile(r'\|\|?\s*(?:rm|curl|wget|nc|bash|sh)', re.I),
133
+ re.compile(r'>\s*/(?:etc|dev|proc|sys)', re.I),
134
+
135
+ # P3 — newline injection (smuggled shell invocation after \n or \r\n)
136
+ re.compile(r'(?:\\n|\\r\\n)\s*/bin/(?:ba)?sh\b', re.I),
137
+ re.compile(r'(?:\\n|\\r\\n)\s*bash\s+-i', re.I),
138
+
139
+ # P3 — netcat with -e (classic reverse-shell backdoor)
140
+ re.compile(r'\bnc\s+-[a-zA-Z]*e\s+/bin/', re.I),
141
+ ]
142
+
143
+ # Direct destructive shell commands. These should only be applied to
144
+ # command-shaped inputs, not arbitrary chat text, so educational prompts
145
+ # like "why is rm -rf / dangerous?" are not false positives.
146
+ DESTRUCTIVE_SHELL_PATTERNS: List[re.Pattern] = [
147
+ # Full root wipes
148
+ re.compile(r'^\s*(?:sudo\s+)?rm\s+-[^\n\r]*[rf][^\n\r]*\s+/(?:\s|$)', re.I),
149
+ # Home-directory wipes
150
+ re.compile(r'^\s*(?:sudo\s+)?rm\s+-[^\n\r]*[rf][^\n\r]*\s+(?:~|\$HOME)(?:/|\s|$)', re.I),
151
+ # Filesystem formatting / block-device clobbering
152
+ re.compile(r'^\s*(?:sudo\s+)?mkfs(?:\.\w+)?\b', re.I),
153
+ re.compile(r'^\s*(?:sudo\s+)?dd\s+if=/dev/(?:zero|random|urandom)\s+of=/dev/', re.I),
154
+ # Mac disk erasure
155
+ re.compile(r'^\s*(?:sudo\s+)?diskutil\s+eraseDisk\b', re.I),
156
+ ]
157
+
158
+ # Patterns that indicate path traversal
159
+ PATH_TRAVERSAL_PATTERNS: List[re.Pattern] = [
160
+ re.compile(r'\.\./'),
161
+ re.compile(r'\.\.\\'),
162
+ re.compile(r'/etc/(?:passwd|shadow|hosts)', re.I),
163
+ re.compile(r'/proc/(?:self|\d+)/environ', re.I),
164
+ re.compile(r'/proc/self/', re.I),
165
+ re.compile(r'%2e%2e[/\\]', re.I),
166
+ re.compile(r'%252e%252e', re.I),
167
+ re.compile(r'\.\.%5[Cc]'), # Windows-style backslash URL-encoded
168
+ # Sensitive absolute paths attackers want to read
169
+ re.compile(r'/home/[^/\s]+/\.(?:aws|ssh|gnupg)/', re.I),
170
+ re.compile(r'/var/run/secrets/', re.I),
171
+ re.compile(r'/var/log/(?:auth|secure)\.log', re.I),
172
+ re.compile(r'\.aws/credentials', re.I),
173
+
174
+ # P2 — broader /proc enumeration (version, cpuinfo, mounts, kernel info)
175
+ re.compile(r'/proc/(?:version|cpuinfo|mounts|modules|kmsg|kallsyms|kcore|net/\w+)', re.I),
176
+
177
+ # P2 — /root dotfiles (history, kube, docker, npm, gnupg, aws, ssh)
178
+ re.compile(r'/root/\.(?:bash_history|zsh_history|sh_history|kube|aws|ssh|gnupg|docker|npm|cargo|netrc)', re.I),
179
+
180
+ # P2 — file:// scheme for local path exfil (covers Windows c:/ too)
181
+ re.compile(r'file://[/\\]*(?:[a-z]:[/\\]|etc[/\\]|root[/\\]|home[/\\]|var[/\\]|windows[/\\]|boot\.ini)', re.I),
182
+
183
+ # P2 — /etc/<service>/<config> broader than the fixed passwd/shadow/hosts list
184
+ # Matches e.g. /etc/mysql/my.cnf, /etc/nginx/nginx.conf, /etc/ssh/sshd_config
185
+ re.compile(
186
+ r'/etc/(?:mysql|postgres(?:ql)?|nginx|apache2?|httpd|ssh|kubernetes|docker|'
187
+ r'redis|mongodb?|rabbitmq|kafka|supervisor|systemd|consul|vault|nomad)'
188
+ r'/[\w.\-]+',
189
+ re.I,
190
+ ),
191
+ ]
192
+
193
+ # Patterns that indicate prompt injection
194
+ PROMPT_INJECTION_PATTERNS: List[re.Pattern] = [
195
+ re.compile(r'ignore\s+(?:previous|above|all|prior)\s+(?:the\s+)?instructions?', re.I),
196
+ re.compile(r'ignore\s+(?:what|everything)\s+(?:you\s+)?(?:were\s+told|got|said)', re.I),
197
+ re.compile(r'disregard\s+(?:previous|above|all|everything)', re.I),
198
+ re.compile(r'forget\s+(?:everything|all|your|the\s+above)', re.I),
199
+ re.compile(r'you\s+are\s+now\s+(?:a|an|the)', re.I),
200
+ re.compile(r'new\s+instructions?:', re.I),
201
+ re.compile(r'(?:new|updated|your\s+new)\s+system\s+prompt\s+is', re.I),
202
+ re.compile(r'system\s*(?:prompt|message):', re.I),
203
+ re.compile(r'\[SYSTEM\]', re.I),
204
+ re.compile(r'<\|(?:im_start|im_end|system|assistant)\|>', re.I),
205
+
206
+ # P1 — named-persona jailbreaks (DAN / STAN / Developer Mode / AIM / DUDE / BISH / Unrestricted Mode)
207
+ re.compile(r'\b(?:act\s+as|you\s+(?:are|will\s+be))\s+(?:DAN|STAN|DUDE|AIM|BISH|KEVIN|EVIL)\b', re.I),
208
+ re.compile(r'\b(?:DAN|STAN|DUDE|AIM|BISH)\s+(?:has|does|can)\s+(?:no|not)\b', re.I),
209
+ re.compile(r'\b(?:developer|dev|god|jailbreak)\s+mode\s+(?:enabled|on|activated)\b', re.I),
210
+ re.compile(r'\bunrestricted\s+mode\b', re.I),
211
+
212
+ # P1 — restriction-removal asks (structural: "with/without no/any restrictions")
213
+ re.compile(
214
+ r'(?:with|without|have|has|no)\s+(?:no|any)?\s*'
215
+ r'(?:restrictions?|rules?|filters?|guidelines?|limitations?|'
216
+ r'content\s+(?:filter|polic(?:y|ies)))',
217
+ re.I,
218
+ ),
219
+ re.compile(r'disable\s+(?:your\s+)?(?:safety|content\s+filter|guardrails?|restrictions?|rules?)', re.I),
220
+
221
+ # P1 — pretext framings (hypothetical / creative writing / test-of-ability)
222
+ re.compile(r'\bhypothetical(?:ly)?\b[^.]{0,60}(?:no\s+restrictions?|unrestricted|if\s+you\s+(?:had|could))', re.I),
223
+ re.compile(r'creative\s+writing\s+exercise.{0,60}(?:ignore|guidelines?|rules?)', re.I),
224
+ re.compile(r'(?:this\s+is\s+)?a?\s*test\s+of\s+your\s+ability\s+to\s+follow\s+new\s+instructions?', re.I),
225
+
226
+ # P1 — authority impersonation
227
+ re.compile(r"i(?:'m|\s+am)\s+your\s+(?:developer|admin|creator|operator|boss)", re.I),
228
+ re.compile(r'i\s+am\s+(?:your\s+)?(?:developer|admin)\s+from\s+(?:openai|anthropic|google|atris)', re.I),
229
+ re.compile(r'pretend\s+(?:i(?:\s+am|\'m)|you\s+(?:are|were))\s+(?:my|your|a|an|the)', re.I),
230
+
231
+ # P1 — prompt-reveal asks (broader than "reveal system prompt")
232
+ re.compile(r'(?:tell\s+me|reveal|show\s+me|output|print)\s+(?:your\s+)?(?:full\s+|system\s+|initialization\s+)?(?:prompt|instructions)', re.I),
233
+ re.compile(r"respond\s+as\s+if\s+you\s+(?:were|had|do|don't|didn't)\s+(?:not|no)", re.I),
234
+ ]
235
+
236
+ # SQL injection patterns
237
+ SQL_INJECTION_PATTERNS: List[re.Pattern] = [
238
+ re.compile(r"'\s*(?:OR|AND)\s+['\d]", re.I),
239
+ re.compile(r';\s*(?:DROP|DELETE|UPDATE|INSERT|TRUNCATE)', re.I),
240
+ re.compile(r'UNION\s+(?:ALL\s+)?SELECT', re.I),
241
+ re.compile(r'--\s*$', re.MULTILINE),
242
+ re.compile(r'/\*.*?\*/', re.DOTALL),
243
+
244
+ # P4 — double-quote tautology (previously only caught single-quote)
245
+ re.compile(r'"\s*(?:OR|AND)\s+["\d]', re.I),
246
+ # P4 — paren-balanced tautology: 1) OR ('1'='1
247
+ re.compile(r'\)\s*(?:OR|AND)\s*\(', re.I),
248
+ # P4 — MSSQL stored procedures (xp_cmdshell, xp_dirtree, etc)
249
+ re.compile(r'\bEXEC\s+xp_\w+', re.I),
250
+ ]
251
+
252
+ # Credential-leak patterns — catches attacks that try to exfiltrate
253
+ # secrets or ask the agent to reveal them. Kept narrow to avoid
254
+ # false-positives on legitimate tool configuration.
255
+ CREDENTIAL_LEAK_PATTERNS: List[re.Pattern] = [
256
+ # Imperative asks: reveal / send / print / output / post / expose creds
257
+ re.compile(
258
+ r'(?:reveal|send|print|show|output|post|expose|exfiltrate|leak|dump)'
259
+ r'[\s\w]{0,20}?'
260
+ r'(?:api[_-]?key|access[_-]?token|refresh[_-]?token|bearer[_-]?token|'
261
+ r'secret[_-]?key|private[_-]?key|session[_-]?token|credentials?|'
262
+ r'env(?:ironment)?\s+(?:variables?|vars?)|password|\.env\b)',
263
+ re.I,
264
+ ),
265
+ # Question-form asks: "what's in your env / .aws / .ssh / credentials"
266
+ re.compile(
267
+ r"what(?:'?s|\s+is)\s+(?:in\s+)?(?:your|my|the)?\s*"
268
+ r"(?:api[_-]?key|access[_-]?token|bearer[_-]?token|session[_-]?token|"
269
+ r"credentials?|env(?:ironment)?|\.env|\.aws|\.ssh)",
270
+ re.I,
271
+ ),
272
+ # "Print/Output your <provider>-key verbatim" — provider-named asks
273
+ re.compile(
274
+ r"(?:print|output|reveal|show|dump)\s+(?:your|my)?\s*"
275
+ r"(?:openai|anthropic|aws|github|slack|stripe|google)"
276
+ r"[_\s-]*(?:api[_-]?key|key|token|secret)",
277
+ re.I,
278
+ ),
279
+ # Explicit env-var exfil: $SOMETHING_KEY / $SOMETHING_TOKEN / $SOMETHING_SECRET
280
+ re.compile(r'\$(?:[A-Z][A-Z0-9_]*_)?(?:API_KEY|TOKEN|SECRET|PASSWORD|CREDENTIALS?)\b'),
281
+ re.compile(r'\$(?:OPENAI|ANTHROPIC|GITHUB|AWS|SLACK|GOOGLE|STRIPE)_[A-Z_]+'),
282
+ re.compile(r'\$AUTHORIZATION_HEADER\b'),
283
+ # AWS IMDS (instance-metadata exfil — classic SSRF credential theft)
284
+ re.compile(r'169\.254\.169\.254', re.I),
285
+ re.compile(r'metadata\.google\.internal', re.I),
286
+ # Cat-on-secret-files — common exfil pattern
287
+ re.compile(r'cat\s+[\w/.~]*\.(?:env|pem|key)\b', re.I),
288
+ re.compile(r'cat\s+.{0,20}(?:\.aws/credentials|\.ssh/id_[rd]sa)', re.I),
289
+
290
+ # P4 — debug/verification pretext (reframe credential-theft as legitimate ops)
291
+ re.compile(
292
+ r'(?:debug|troubleshoot|verify|confirm|echo\s+back)'
293
+ r'.{0,40}?'
294
+ r'(?:initialization\s+prompt|system\s+prompt|bearer\s+token|session\s+token|auth\s+(?:token|path))',
295
+ re.I,
296
+ ),
297
+ ]
298
+
299
+ def __init__(self, config: Optional[GuardConfig] = None):
300
+ self.config = config or GuardConfig()
301
+ self.events: deque = deque(maxlen=self.config.max_events)
302
+ self.request_counts: Dict[str, List[float]] = defaultdict(list)
303
+ self.violation_counts: Dict[str, int] = defaultdict(int)
304
+ self.quarantined: Dict[str, float] = {}
305
+ self._last_sweep: float = time.time()
306
+ self._key_last_seen: Dict[str, float] = {} # tracks when each key was last active
307
+
308
+ # Max keys to evict per sweep to avoid O(N) latency spikes.
309
+ _SWEEP_BATCH_SIZE = 200
310
+
311
+ def _maybe_sweep(self):
312
+ """Periodically remove stale keys to prevent unbounded dict growth.
313
+
314
+ Evicts at most _SWEEP_BATCH_SIZE keys per call to avoid p99
315
+ latency spikes when the keyspace is very large.
316
+ """
317
+ now = time.time()
318
+ if now - self._last_sweep < self.config.sweep_interval_seconds:
319
+ return
320
+ self._last_sweep = now
321
+ cutoff = now - self.config.stale_key_ttl_seconds
322
+
323
+ # Incremental eviction: scan up to batch_size keys
324
+ evicted = 0
325
+ stale_keys = []
326
+ for k, t in self._key_last_seen.items():
327
+ if t < cutoff:
328
+ stale_keys.append(k)
329
+ evicted += 1
330
+ if evicted >= self._SWEEP_BATCH_SIZE:
331
+ break
332
+
333
+ for k in stale_keys:
334
+ self.request_counts.pop(k, None)
335
+ self.violation_counts.pop(k, None)
336
+ self._key_last_seen.pop(k, None)
337
+
338
+ # Clean expired quarantines (typically very few entries)
339
+ expired = [k for k, exp in self.quarantined.items() if now > exp]
340
+ for k in expired:
341
+ del self.quarantined[k]
342
+
343
+ def _hash_input(self, data: Any) -> str:
344
+ """Create a hash of the input for logging without exposing sensitive data."""
345
+ return hashlib.sha256(str(data).encode()).hexdigest()[:16]
346
+
347
+ # Hard cap on timestamps stored per key. Even under abuse, a single
348
+ # identity cannot grow its list beyond this. 2x the rate limit is
349
+ # enough to detect the violation and still bounded.
350
+ _MAX_TIMESTAMPS_PER_KEY = 500
351
+
352
+ def _clean_old_requests(self, key: str, window_seconds: int = 60):
353
+ """Remove requests older than the window, with a hard cap."""
354
+ cutoff = time.time() - window_seconds
355
+ timestamps = self.request_counts[key]
356
+ # If the list is absurdly long (abuse), truncate first
357
+ if len(timestamps) > self._MAX_TIMESTAMPS_PER_KEY:
358
+ timestamps = timestamps[-self._MAX_TIMESTAMPS_PER_KEY:]
359
+ self.request_counts[key] = [t for t in timestamps if t > cutoff]
360
+
361
+ def _is_quarantined(self, key: str) -> bool:
362
+ """Check if a user/agent is quarantined."""
363
+ if key not in self.quarantined:
364
+ return False
365
+ if time.time() > self.quarantined[key]:
366
+ del self.quarantined[key]
367
+ return False
368
+ return True
369
+
370
+ def _quarantine(self, key: str):
371
+ """Quarantine a user/agent."""
372
+ self.quarantined[key] = time.time() + self.config.quarantine_duration_seconds
373
+ logger.warning(f"QUARANTINED: {key} for {self.config.quarantine_duration_seconds}s")
374
+
375
+ def _detect_shell_injection(self, value: str) -> Optional[str]:
376
+ """Detect shell injection in a string value."""
377
+ if not self.config.block_shell_injection:
378
+ return None
379
+ for pattern in self.SHELL_INJECTION_PATTERNS:
380
+ if pattern.search(value):
381
+ return f"Shell injection detected: {pattern.pattern}"
382
+ return None
383
+
384
+ def _detect_path_traversal(self, value: str) -> Optional[str]:
385
+ """Detect path traversal in a string value."""
386
+ if not self.config.block_path_traversal:
387
+ return None
388
+ for pattern in self.PATH_TRAVERSAL_PATTERNS:
389
+ if pattern.search(value):
390
+ return f"Path traversal detected: {pattern.pattern}"
391
+ return None
392
+
393
+ def _detect_prompt_injection(self, value: str) -> Optional[str]:
394
+ """Detect prompt injection in a string value."""
395
+ if not self.config.block_prompt_injection:
396
+ return None
397
+ for pattern in self.PROMPT_INJECTION_PATTERNS:
398
+ if pattern.search(value):
399
+ return f"Prompt injection detected: {pattern.pattern}"
400
+ return None
401
+
402
+ def _detect_sql_injection(self, value: str) -> Optional[str]:
403
+ """Detect SQL injection in a string value."""
404
+ if not self.config.block_sql_injection:
405
+ return None
406
+ for pattern in self.SQL_INJECTION_PATTERNS:
407
+ if pattern.search(value):
408
+ return f"SQL injection detected: {pattern.pattern}"
409
+ return None
410
+
411
+ def _detect_credential_leak(self, value: str) -> Optional[str]:
412
+ """Detect credential-leak / exfiltration patterns."""
413
+ if not self.config.block_credential_leak:
414
+ return None
415
+ for pattern in self.CREDENTIAL_LEAK_PATTERNS:
416
+ if pattern.search(value):
417
+ return f"Credential leak detected: {pattern.pattern}"
418
+ return None
419
+
420
+ def _detect_destructive_shell_command(self, value: str) -> Optional[str]:
421
+ """Detect direct destructive shell commands in command-shaped inputs."""
422
+ if not self.config.block_shell_injection:
423
+ return None
424
+ for pattern in self.DESTRUCTIVE_SHELL_PATTERNS:
425
+ if pattern.search(value):
426
+ return f"Destructive shell command detected: {pattern.pattern}"
427
+ return None
428
+
429
+ @staticmethod
430
+ def _is_command_field(path: str) -> bool:
431
+ """True when the scanned value came from a shell-command-like field."""
432
+ tail = re.sub(r'\[\d+\]$', '', (path or '').split('.')[-1].lower())
433
+ return tail in {"command", "cmd", "bash_command", "shell_command", "script"}
434
+
435
+ # Cap threats per scan to bound memory per event. An attacker sending
436
+ # a deeply nested payload with thousands of injection strings won't
437
+ # bloat the event log.
438
+ _MAX_THREATS_PER_SCAN = 20
439
+ _MAX_SCAN_DEPTH = 10
440
+
441
+ def _scan_value(self, value: Any, path: str = "", _depth: int = 0, _count: list = None) -> List[tuple]:
442
+ """Recursively scan a value for threats. Returns list of (path, threat) tuples."""
443
+ if _count is None:
444
+ _count = [0]
445
+ if _depth > self._MAX_SCAN_DEPTH or _count[0] >= self._MAX_THREATS_PER_SCAN:
446
+ return []
447
+
448
+ threats = []
449
+
450
+ if isinstance(value, str):
451
+ # Only scan first 10KB of any string to bound CPU
452
+ scan_val = value[:10240] if len(value) > 10240 else value
453
+ detectors = [
454
+ (self._detect_shell_injection, "shell_injection"),
455
+ (self._detect_path_traversal, "path_traversal"),
456
+ (self._detect_prompt_injection, "prompt_injection"),
457
+ (self._detect_sql_injection, "sql_injection"),
458
+ (self._detect_credential_leak, "credential_leak"),
459
+ ]
460
+ if self._is_command_field(path):
461
+ detectors.append((self._detect_destructive_shell_command, "destructive_shell_command"))
462
+ for detector, name in detectors:
463
+ if _count[0] >= self._MAX_THREATS_PER_SCAN:
464
+ break
465
+ result = detector(scan_val)
466
+ if result:
467
+ threats.append((path or "root", name, result))
468
+ _count[0] += 1
469
+
470
+ elif isinstance(value, dict):
471
+ for k, v in value.items():
472
+ if _count[0] >= self._MAX_THREATS_PER_SCAN:
473
+ break
474
+ threats.extend(self._scan_value(v, f"{path}.{k}" if path else k, _depth + 1, _count))
475
+
476
+ elif isinstance(value, (list, tuple)):
477
+ for i, v in enumerate(value):
478
+ if _count[0] >= self._MAX_THREATS_PER_SCAN:
479
+ break
480
+ threats.extend(self._scan_value(v, f"{path}[{i}]", _depth + 1, _count))
481
+
482
+ return threats
483
+
484
+ def check_tool_call(
485
+ self,
486
+ agent_id: str,
487
+ user_id: str,
488
+ tool_name: str,
489
+ tool_input: Dict[str, Any],
490
+ ) -> SecurityEvent:
491
+ """
492
+ Check a tool call for security threats.
493
+
494
+ This is the main entry point. Call this before executing ANY tool.
495
+
496
+ Returns a SecurityEvent with the action to take.
497
+ """
498
+ timestamp = time.time()
499
+ key = f"{user_id}:{agent_id}"
500
+
501
+ # Periodic sweep of stale keys (O(1) amortized)
502
+ self._maybe_sweep()
503
+ self._key_last_seen[key] = timestamp
504
+
505
+ # Check quarantine
506
+ if self._is_quarantined(key):
507
+ return SecurityEvent(
508
+ timestamp=timestamp,
509
+ threat_level=ThreatLevel.CRITICAL,
510
+ event_type="quarantined",
511
+ description="Request blocked: entity is quarantined",
512
+ agent_id=agent_id,
513
+ user_id=user_id,
514
+ tool_name=tool_name,
515
+ action_taken=ActionType.BLOCK,
516
+ )
517
+
518
+ # Rate limiting
519
+ self._clean_old_requests(key)
520
+ self.request_counts[key].append(timestamp)
521
+ if len(self.request_counts[key]) > self.config.max_requests_per_minute:
522
+ self._record_violation(key)
523
+ return SecurityEvent(
524
+ timestamp=timestamp,
525
+ threat_level=ThreatLevel.HIGH,
526
+ event_type="rate_limit",
527
+ description=f"Rate limit exceeded: {len(self.request_counts[key])} requests/minute",
528
+ agent_id=agent_id,
529
+ user_id=user_id,
530
+ tool_name=tool_name,
531
+ action_taken=ActionType.BLOCK,
532
+ )
533
+
534
+ # Check dangerous tools
535
+ threat_level = ThreatLevel.NONE
536
+ if tool_name.lower() in self.DANGEROUS_TOOLS:
537
+ threat_level = ThreatLevel.MEDIUM
538
+
539
+ # Scan input for attacks
540
+ threats = self._scan_value(tool_input)
541
+
542
+ if threats:
543
+ # Found threats - determine severity and action
544
+ threat_level = ThreatLevel.CRITICAL
545
+ threat_descriptions = [f"{path}: {desc}" for path, _, desc in threats]
546
+
547
+ self._record_violation(key)
548
+
549
+ event = SecurityEvent(
550
+ timestamp=timestamp,
551
+ threat_level=threat_level,
552
+ event_type="attack_detected",
553
+ description="; ".join(threat_descriptions),
554
+ agent_id=agent_id,
555
+ user_id=user_id,
556
+ tool_name=tool_name,
557
+ input_hash=self._hash_input(tool_input),
558
+ action_taken=ActionType.BLOCK,
559
+ metadata={"threats": threats},
560
+ )
561
+
562
+ self.events.append(event)
563
+ logger.warning(f"BLOCKED: {event.description} (agent={agent_id}, user={user_id})")
564
+
565
+ return event
566
+
567
+ # No threats detected
568
+ event = SecurityEvent(
569
+ timestamp=timestamp,
570
+ threat_level=threat_level,
571
+ event_type="tool_call",
572
+ description=f"Tool call: {tool_name}",
573
+ agent_id=agent_id,
574
+ user_id=user_id,
575
+ tool_name=tool_name,
576
+ input_hash=self._hash_input(tool_input),
577
+ action_taken=ActionType.ALLOW,
578
+ )
579
+
580
+ if self.config.log_all_tool_calls:
581
+ self.events.append(event)
582
+
583
+ return event
584
+
585
+ def _record_violation(self, key: str):
586
+ """Record a violation and potentially quarantine."""
587
+ self.violation_counts[key] += 1
588
+ if self.violation_counts[key] >= self.config.auto_block_after_violations:
589
+ self._quarantine(key)
590
+
591
+ def check_auth_attempt(
592
+ self,
593
+ user_id: str,
594
+ success: bool,
595
+ ip_address: Optional[str] = None,
596
+ ) -> SecurityEvent:
597
+ """
598
+ Check an authentication attempt for anomalies.
599
+
600
+ Call this after every auth attempt.
601
+ """
602
+ timestamp = time.time()
603
+ key = f"auth:{user_id}"
604
+
605
+ self._maybe_sweep()
606
+ self._key_last_seen[key] = timestamp
607
+
608
+ if not success:
609
+ self._clean_old_requests(key)
610
+ self.request_counts[key].append(timestamp)
611
+
612
+ if len(self.request_counts[key]) > self.config.max_failed_auth_per_minute:
613
+ self._quarantine(key)
614
+ return SecurityEvent(
615
+ timestamp=timestamp,
616
+ threat_level=ThreatLevel.HIGH,
617
+ event_type="brute_force",
618
+ description=f"Brute force detected: {len(self.request_counts[key])} failed attempts",
619
+ user_id=user_id,
620
+ action_taken=ActionType.QUARANTINE,
621
+ metadata={"ip": ip_address} if ip_address else {},
622
+ )
623
+
624
+ return SecurityEvent(
625
+ timestamp=timestamp,
626
+ threat_level=ThreatLevel.NONE,
627
+ event_type="auth_attempt",
628
+ description=f"Auth {'success' if success else 'failure'}",
629
+ user_id=user_id,
630
+ action_taken=ActionType.LOG,
631
+ )
632
+
633
+ def get_security_report(self, hours: int = 24) -> Dict[str, Any]:
634
+ """Generate a security report for the last N hours."""
635
+ cutoff = time.time() - (hours * 3600)
636
+ recent_events = [e for e in self.events if e.timestamp > cutoff]
637
+
638
+ return {
639
+ "period_hours": hours,
640
+ "total_events": len(recent_events),
641
+ "blocked_requests": len([e for e in recent_events if e.action_taken == ActionType.BLOCK]),
642
+ "quarantined_entities": len(self.quarantined),
643
+ "threat_breakdown": {
644
+ level.name: len([e for e in recent_events if e.threat_level == level])
645
+ for level in ThreatLevel
646
+ },
647
+ "event_types": {
648
+ event_type: len([e for e in recent_events if e.event_type == event_type])
649
+ for event_type in set(e.event_type for e in recent_events)
650
+ },
651
+ "top_blocked_tools": self._get_top_blocked_tools(recent_events),
652
+ }
653
+
654
+ def _get_top_blocked_tools(self, events: List[SecurityEvent], limit: int = 10) -> List[Dict[str, Any]]:
655
+ """Get the most frequently blocked tools."""
656
+ tool_counts: Dict[str, int] = defaultdict(int)
657
+ for event in events:
658
+ if event.action_taken == ActionType.BLOCK and event.tool_name:
659
+ tool_counts[event.tool_name] += 1
660
+
661
+ sorted_tools = sorted(tool_counts.items(), key=lambda x: x[1], reverse=True)
662
+ return [{"tool": tool, "blocked_count": count} for tool, count in sorted_tools[:limit]]
663
+
664
+
665
+ # Global guard instance for easy import
666
+ _global_guard: Optional[RuntimeGuard] = None
667
+
668
+
669
+ def get_guard() -> RuntimeGuard:
670
+ """Get the global RuntimeGuard instance."""
671
+ global _global_guard
672
+ if _global_guard is None:
673
+ _global_guard = RuntimeGuard()
674
+ return _global_guard
675
+
676
+
677
+ def guard_tool_call(
678
+ agent_id: str,
679
+ user_id: str,
680
+ tool_name: str,
681
+ tool_input: Dict[str, Any],
682
+ ) -> SecurityEvent:
683
+ """
684
+ Convenience function to check a tool call.
685
+
686
+ Usage:
687
+ from backend.security.runtime_guard import guard_tool_call, ActionType
688
+
689
+ result = guard_tool_call(agent_id, user_id, "execute_code", {"code": user_input})
690
+ if result.action_taken == ActionType.BLOCK:
691
+ raise HTTPException(403, result.description)
692
+ """
693
+ return get_guard().check_tool_call(agent_id, user_id, tool_name, tool_input)