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.
- package/GETTING_STARTED.md +65 -131
- package/README.md +18 -2
- package/atris/GETTING_STARTED.md +65 -131
- package/atris/PERSONA.md +5 -1
- package/atris/atris.md +122 -153
- package/atris/skills/aeo/SKILL.md +117 -0
- package/atris/skills/atris/SKILL.md +49 -25
- package/atris/skills/create-member/SKILL.md +29 -9
- package/atris/skills/endgame/SKILL.md +9 -0
- package/atris/skills/research-search/SKILL.md +167 -0
- package/atris/skills/research-search/arxiv_search.py +157 -0
- package/atris/skills/research-search/program.md +48 -0
- package/atris/skills/research-search/results.tsv +6 -0
- package/atris/skills/research-search/scholar_search.py +154 -0
- package/atris/skills/tidy/SKILL.md +36 -21
- package/atris/team/_template/MEMBER.md +2 -0
- package/atris/team/validator/MEMBER.md +35 -1
- package/atris.md +118 -178
- package/bin/atris.js +46 -12
- package/cli/__pycache__/atris_code.cpython-314.pyc +0 -0
- package/cli/__pycache__/runtime_guard.cpython-312.pyc +0 -0
- package/cli/__pycache__/runtime_guard.cpython-314.pyc +0 -0
- package/cli/atris_code.py +889 -0
- package/cli/runtime_guard.py +693 -0
- package/commands/align.js +16 -0
- package/commands/app.js +316 -0
- package/commands/autopilot.js +863 -23
- package/commands/brainstorm.js +7 -5
- package/commands/business.js +677 -2
- package/commands/clean.js +19 -3
- package/commands/computer.js +2022 -43
- package/commands/context-sync.js +5 -0
- package/commands/integrations.js +14 -9
- package/commands/lifecycle.js +12 -0
- package/commands/plugin.js +24 -0
- package/commands/pull.js +86 -11
- package/commands/push.js +153 -9
- package/commands/serve.js +1 -0
- package/commands/sync.js +272 -76
- package/commands/verify.js +50 -1
- package/commands/wiki.js +27 -2
- package/commands/workflow.js +24 -9
- package/lib/file-ops.js +13 -1
- package/lib/journal.js +23 -0
- package/lib/manifest.js +3 -0
- package/lib/scorecard.js +42 -4
- package/lib/sync-telemetry.js +59 -0
- package/lib/todo.js +6 -0
- package/lib/wiki.js +150 -6
- package/lib/workspace-safety.js +87 -0
- package/package.json +2 -1
- package/utils/api.js +19 -0
- package/utils/auth.js +25 -1
- package/utils/config.js +24 -0
- 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)
|