tweek 0.4.1__py3-none-any.whl → 0.4.2__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.
- tweek/__init__.py +1 -1
- tweek/cli_core.py +23 -6
- tweek/cli_install.py +361 -91
- tweek/cli_uninstall.py +119 -36
- tweek/config/families.yaml +13 -0
- tweek/config/models.py +31 -3
- tweek/config/patterns.yaml +126 -2
- tweek/diagnostics.py +124 -1
- tweek/hooks/break_glass.py +70 -47
- tweek/hooks/overrides.py +19 -1
- tweek/hooks/post_tool_use.py +6 -2
- tweek/hooks/pre_tool_use.py +19 -2
- tweek/hooks/wrapper_post_tool_use.py +121 -0
- tweek/hooks/wrapper_pre_tool_use.py +121 -0
- tweek/integrations/openclaw.py +70 -60
- tweek/integrations/openclaw_detection.py +140 -0
- tweek/integrations/openclaw_server.py +359 -86
- tweek/logging/security_log.py +22 -0
- tweek/memory/safety.py +7 -3
- tweek/memory/store.py +31 -10
- tweek/plugins/base.py +9 -1
- tweek/plugins/detectors/openclaw.py +31 -92
- tweek/plugins/screening/heuristic_scorer.py +12 -1
- tweek/plugins/screening/local_model_reviewer.py +9 -0
- tweek/security/language.py +2 -1
- tweek/security/llm_reviewer.py +45 -18
- tweek/security/local_model.py +21 -0
- tweek/security/model_registry.py +2 -2
- tweek/security/rate_limiter.py +99 -1
- tweek/skills/guard.py +30 -7
- {tweek-0.4.1.dist-info → tweek-0.4.2.dist-info}/METADATA +1 -1
- {tweek-0.4.1.dist-info → tweek-0.4.2.dist-info}/RECORD +37 -34
- {tweek-0.4.1.dist-info → tweek-0.4.2.dist-info}/WHEEL +0 -0
- {tweek-0.4.1.dist-info → tweek-0.4.2.dist-info}/entry_points.txt +0 -0
- {tweek-0.4.1.dist-info → tweek-0.4.2.dist-info}/licenses/LICENSE +0 -0
- {tweek-0.4.1.dist-info → tweek-0.4.2.dist-info}/licenses/NOTICE +0 -0
- {tweek-0.4.1.dist-info → tweek-0.4.2.dist-info}/top_level.txt +0 -0
|
@@ -7,13 +7,13 @@ Gateway plugin. Runs on localhost and provides endpoints for skill scanning,
|
|
|
7
7
|
tool screening, output scanning, and fingerprint management.
|
|
8
8
|
|
|
9
9
|
Endpoints:
|
|
10
|
-
POST /scan
|
|
11
|
-
POST /screen
|
|
12
|
-
POST /output
|
|
13
|
-
POST /fingerprint/check
|
|
14
|
-
POST /fingerprint/register
|
|
15
|
-
GET /health
|
|
16
|
-
GET /report/<skill>
|
|
10
|
+
POST /scan - Run 7-layer SkillScanner on a skill directory
|
|
11
|
+
POST /screen - Screen a tool call (pre-execution)
|
|
12
|
+
POST /output - Scan tool output (post-execution)
|
|
13
|
+
POST /fingerprint/check - Check if skill is known/approved
|
|
14
|
+
POST /fingerprint/register - Register approved skill hash
|
|
15
|
+
GET /health - Server health + scanner status
|
|
16
|
+
GET /report/<skill> - Retrieve scan report
|
|
17
17
|
|
|
18
18
|
Usage:
|
|
19
19
|
python -m tweek.integrations.openclaw_server [--port 9878]
|
|
@@ -21,7 +21,10 @@ Usage:
|
|
|
21
21
|
|
|
22
22
|
import json
|
|
23
23
|
import os
|
|
24
|
+
import secrets
|
|
25
|
+
import signal
|
|
24
26
|
import sys
|
|
27
|
+
import time
|
|
25
28
|
from http.server import HTTPServer, BaseHTTPRequestHandler
|
|
26
29
|
from pathlib import Path
|
|
27
30
|
from typing import Any, Dict, Optional
|
|
@@ -30,6 +33,105 @@ from urllib.parse import urlparse
|
|
|
30
33
|
# Default port for the OpenClaw scanning server
|
|
31
34
|
DEFAULT_PORT = 9878
|
|
32
35
|
|
|
36
|
+
# Maximum request body size (10 MB)
|
|
37
|
+
MAX_REQUEST_SIZE = 10 * 1024 * 1024
|
|
38
|
+
|
|
39
|
+
# Token file for bearer auth
|
|
40
|
+
TOKEN_FILE = Path.home() / ".tweek" / ".scanner_token"
|
|
41
|
+
|
|
42
|
+
# PID file for process management
|
|
43
|
+
PID_FILE = Path.home() / ".tweek" / ".scanner.pid"
|
|
44
|
+
|
|
45
|
+
# Allowed base directory for skill scanning
|
|
46
|
+
OPENCLAW_SKILLS_BASE = Path.home() / ".openclaw" / "workspace" / "skills"
|
|
47
|
+
|
|
48
|
+
# Rate limit settings per endpoint (max requests per 60-second window)
|
|
49
|
+
RATE_LIMITS = {
|
|
50
|
+
"/scan": 5,
|
|
51
|
+
"/screen": 60,
|
|
52
|
+
"/output": 60,
|
|
53
|
+
"/fingerprint/check": 30,
|
|
54
|
+
"/fingerprint/register": 10,
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
# Pre-resolved event type names to avoid dynamic attribute lookups
|
|
58
|
+
_EVENT_TYPES = {}
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _init_event_types():
|
|
62
|
+
"""Cache EventType enum values at startup."""
|
|
63
|
+
global _EVENT_TYPES
|
|
64
|
+
try:
|
|
65
|
+
from tweek.logging.security_log import EventType
|
|
66
|
+
_EVENT_TYPES = {e.name: e for e in EventType}
|
|
67
|
+
except Exception:
|
|
68
|
+
_EVENT_TYPES = {}
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _load_or_create_token() -> str:
|
|
72
|
+
"""Load existing auth token or create a new one.
|
|
73
|
+
|
|
74
|
+
Returns:
|
|
75
|
+
The bearer token string.
|
|
76
|
+
"""
|
|
77
|
+
TOKEN_FILE.parent.mkdir(parents=True, exist_ok=True)
|
|
78
|
+
|
|
79
|
+
if TOKEN_FILE.exists():
|
|
80
|
+
token = TOKEN_FILE.read_text().strip()
|
|
81
|
+
if token:
|
|
82
|
+
return token
|
|
83
|
+
|
|
84
|
+
token = secrets.token_urlsafe(32)
|
|
85
|
+
TOKEN_FILE.write_text(token)
|
|
86
|
+
TOKEN_FILE.chmod(0o600)
|
|
87
|
+
return token
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _get_logger():
|
|
91
|
+
"""Get the security logger, returning None if unavailable."""
|
|
92
|
+
try:
|
|
93
|
+
from tweek.logging.security_log import get_logger
|
|
94
|
+
return get_logger()
|
|
95
|
+
except Exception:
|
|
96
|
+
return None
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
class _RateTracker:
|
|
100
|
+
"""Simple per-endpoint rate limiter using sliding window counters."""
|
|
101
|
+
|
|
102
|
+
def __init__(self, limits: Dict[str, int]):
|
|
103
|
+
self._limits = limits
|
|
104
|
+
self._windows: Dict[str, list] = {}
|
|
105
|
+
|
|
106
|
+
def check(self, endpoint: str) -> bool:
|
|
107
|
+
"""Return True if request is allowed, False if rate-limited."""
|
|
108
|
+
limit = self._limits.get(endpoint)
|
|
109
|
+
if limit is None:
|
|
110
|
+
return True
|
|
111
|
+
|
|
112
|
+
now = time.monotonic()
|
|
113
|
+
window = self._windows.setdefault(endpoint, [])
|
|
114
|
+
|
|
115
|
+
# Purge entries older than 60 seconds
|
|
116
|
+
cutoff = now - 60.0
|
|
117
|
+
self._windows[endpoint] = [t for t in window if t > cutoff]
|
|
118
|
+
window = self._windows[endpoint]
|
|
119
|
+
|
|
120
|
+
if len(window) >= limit:
|
|
121
|
+
return False
|
|
122
|
+
|
|
123
|
+
window.append(now)
|
|
124
|
+
return True
|
|
125
|
+
|
|
126
|
+
def retry_after(self, endpoint: str) -> int:
|
|
127
|
+
"""Seconds until the oldest entry in the window expires."""
|
|
128
|
+
window = self._windows.get(endpoint, [])
|
|
129
|
+
if not window:
|
|
130
|
+
return 0
|
|
131
|
+
oldest = min(window)
|
|
132
|
+
remaining = 60.0 - (time.monotonic() - oldest)
|
|
133
|
+
return max(1, int(remaining))
|
|
134
|
+
|
|
33
135
|
|
|
34
136
|
def _scan_skill(skill_dir: str) -> Dict[str, Any]:
|
|
35
137
|
"""
|
|
@@ -57,7 +159,7 @@ def _scan_skill(skill_dir: str) -> Dict[str, Any]:
|
|
|
57
159
|
"layer": f.layer,
|
|
58
160
|
"severity": f.severity,
|
|
59
161
|
"description": f.description,
|
|
60
|
-
"matched_text":
|
|
162
|
+
"matched_text": f.matched_text if hasattr(f, "matched_text") else "",
|
|
61
163
|
}
|
|
62
164
|
for f in report.findings
|
|
63
165
|
],
|
|
@@ -84,21 +186,8 @@ def _screen_tool(tool: str, input_data: Dict, tier: str = "default") -> Dict[str
|
|
|
84
186
|
Screening decision dict
|
|
85
187
|
"""
|
|
86
188
|
from tweek.hooks.pre_tool_use import process_hook
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
logger = MagicMock()
|
|
90
|
-
logger.log_quick = MagicMock()
|
|
91
|
-
|
|
92
|
-
# Build the hook input matching Tweek's expected format
|
|
93
|
-
command = ""
|
|
94
|
-
if tool == "bash":
|
|
95
|
-
command = input_data.get("command", "")
|
|
96
|
-
elif tool in ("file_write", "Write"):
|
|
97
|
-
command = input_data.get("content", "") or input_data.get("file_path", "")
|
|
98
|
-
elif tool in ("web_fetch", "WebFetch"):
|
|
99
|
-
command = input_data.get("url", "") or input_data.get("prompt", "")
|
|
100
|
-
else:
|
|
101
|
-
command = json.dumps(input_data)[:500]
|
|
189
|
+
|
|
190
|
+
logger = _get_logger()
|
|
102
191
|
|
|
103
192
|
hook_input = {
|
|
104
193
|
"tool_name": tool,
|
|
@@ -110,6 +199,19 @@ def _screen_tool(tool: str, input_data: Dict, tier: str = "default") -> Dict[str
|
|
|
110
199
|
decision = result.get("hookSpecificOutput", {}).get("permissionDecision", "allow")
|
|
111
200
|
reason = result.get("hookSpecificOutput", {}).get("permissionDecisionReason", "")
|
|
112
201
|
|
|
202
|
+
# Log the screening decision
|
|
203
|
+
if logger:
|
|
204
|
+
evt = _EVENT_TYPES.get("ALLOWED") if decision == "allow" else _EVENT_TYPES.get("USER_PROMPTED")
|
|
205
|
+
if evt:
|
|
206
|
+
logger.log_quick(
|
|
207
|
+
evt,
|
|
208
|
+
tool,
|
|
209
|
+
command=json.dumps(input_data)[:500],
|
|
210
|
+
decision=decision,
|
|
211
|
+
decision_reason=reason[:200],
|
|
212
|
+
source="openclaw_server",
|
|
213
|
+
)
|
|
214
|
+
|
|
113
215
|
return {
|
|
114
216
|
"decision": decision,
|
|
115
217
|
"reason": reason,
|
|
@@ -117,21 +219,35 @@ def _screen_tool(tool: str, input_data: Dict, tier: str = "default") -> Dict[str
|
|
|
117
219
|
"tier": tier,
|
|
118
220
|
}
|
|
119
221
|
except Exception as e:
|
|
222
|
+
# Fail-closed: default to "ask" on errors, not "allow"
|
|
223
|
+
if logger:
|
|
224
|
+
evt = _EVENT_TYPES.get("ERROR")
|
|
225
|
+
if evt:
|
|
226
|
+
logger.log_quick(
|
|
227
|
+
evt,
|
|
228
|
+
tool,
|
|
229
|
+
command=json.dumps(input_data)[:200],
|
|
230
|
+
decision="ask",
|
|
231
|
+
decision_reason=f"Screening error: {e}",
|
|
232
|
+
source="openclaw_server",
|
|
233
|
+
)
|
|
120
234
|
return {
|
|
121
|
-
"decision": "
|
|
122
|
-
"reason": f"Screening error: {e}",
|
|
235
|
+
"decision": "ask",
|
|
236
|
+
"reason": f"Screening error: {e}. Manual review recommended.",
|
|
123
237
|
"tool": tool,
|
|
124
238
|
"tier": tier,
|
|
125
239
|
"error": str(e),
|
|
240
|
+
"degraded": True,
|
|
126
241
|
}
|
|
127
242
|
|
|
128
243
|
|
|
129
|
-
def _scan_output(content: str) -> Dict[str, Any]:
|
|
244
|
+
def _scan_output(content: str, tool_name: str = "unknown_openclaw_tool") -> Dict[str, Any]:
|
|
130
245
|
"""
|
|
131
246
|
Scan tool output for credential leakage and exfiltration attempts.
|
|
132
247
|
|
|
133
248
|
Args:
|
|
134
249
|
content: Tool output text to scan
|
|
250
|
+
tool_name: The actual tool that produced the output
|
|
135
251
|
|
|
136
252
|
Returns:
|
|
137
253
|
Scanning result dict
|
|
@@ -139,8 +255,8 @@ def _scan_output(content: str) -> Dict[str, Any]:
|
|
|
139
255
|
from tweek.hooks.post_tool_use import process_hook
|
|
140
256
|
|
|
141
257
|
input_data = {
|
|
142
|
-
"tool_name":
|
|
143
|
-
"tool_input": {"file_path": "/
|
|
258
|
+
"tool_name": tool_name,
|
|
259
|
+
"tool_input": {"file_path": f"/openclaw/{tool_name}/output"},
|
|
144
260
|
"tool_response": content,
|
|
145
261
|
}
|
|
146
262
|
|
|
@@ -209,7 +325,7 @@ def _get_report(skill_name: str) -> Dict[str, Any]:
|
|
|
209
325
|
Retrieve the most recent scan report for a skill.
|
|
210
326
|
|
|
211
327
|
Args:
|
|
212
|
-
skill_name: Name of the skill
|
|
328
|
+
skill_name: Name of the skill (must not contain path separators)
|
|
213
329
|
|
|
214
330
|
Returns:
|
|
215
331
|
Report dict or error
|
|
@@ -235,6 +351,37 @@ def _get_report(skill_name: str) -> Dict[str, Any]:
|
|
|
235
351
|
return {"error": f"Failed to read report: {e}"}
|
|
236
352
|
|
|
237
353
|
|
|
354
|
+
def _validate_skill_path(path_str: str) -> Optional[str]:
|
|
355
|
+
"""Validate that a skill path resolves under the allowed base directory.
|
|
356
|
+
|
|
357
|
+
Returns None if valid, or an error message string if invalid.
|
|
358
|
+
"""
|
|
359
|
+
try:
|
|
360
|
+
resolved = Path(path_str).resolve()
|
|
361
|
+
except (ValueError, OSError) as e:
|
|
362
|
+
return f"Invalid path: {e}"
|
|
363
|
+
|
|
364
|
+
# Must be under the OpenClaw skills directory
|
|
365
|
+
try:
|
|
366
|
+
resolved.relative_to(OPENCLAW_SKILLS_BASE.resolve())
|
|
367
|
+
except ValueError:
|
|
368
|
+
return f"Path must be under {OPENCLAW_SKILLS_BASE}"
|
|
369
|
+
|
|
370
|
+
return None
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
def _sanitize_skill_name(name: str) -> Optional[str]:
|
|
374
|
+
"""Validate a skill name for use in report lookups.
|
|
375
|
+
|
|
376
|
+
Returns None if valid, or an error message string if invalid.
|
|
377
|
+
"""
|
|
378
|
+
if not name:
|
|
379
|
+
return "Missing skill name"
|
|
380
|
+
if "/" in name or "\\" in name or ".." in name or "\x00" in name:
|
|
381
|
+
return "Invalid characters in skill name"
|
|
382
|
+
return None
|
|
383
|
+
|
|
384
|
+
|
|
238
385
|
class OpenClawScanHandler(BaseHTTPRequestHandler):
|
|
239
386
|
"""HTTP request handler for the OpenClaw scanning server."""
|
|
240
387
|
|
|
@@ -242,17 +389,23 @@ class OpenClawScanHandler(BaseHTTPRequestHandler):
|
|
|
242
389
|
parsed = urlparse(self.path)
|
|
243
390
|
|
|
244
391
|
if parsed.path == "/health":
|
|
392
|
+
# Health endpoint is exempt from auth
|
|
245
393
|
self._respond(200, {
|
|
246
394
|
"status": "ok",
|
|
247
395
|
"service": "tweek-openclaw-scanner",
|
|
248
396
|
"port": self.server.server_address[1],
|
|
249
397
|
})
|
|
250
398
|
elif parsed.path.startswith("/report/"):
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
399
|
+
if not self._check_auth():
|
|
400
|
+
return
|
|
401
|
+
|
|
402
|
+
raw_name = parsed.path[len("/report/"):]
|
|
403
|
+
error = _sanitize_skill_name(raw_name)
|
|
404
|
+
if error:
|
|
405
|
+
self._respond(400, {"error": error})
|
|
254
406
|
return
|
|
255
|
-
|
|
407
|
+
|
|
408
|
+
result = _get_report(raw_name)
|
|
256
409
|
status = 200 if "error" not in result else 404
|
|
257
410
|
self._respond(status, result)
|
|
258
411
|
else:
|
|
@@ -260,64 +413,150 @@ class OpenClawScanHandler(BaseHTTPRequestHandler):
|
|
|
260
413
|
|
|
261
414
|
def do_POST(self):
|
|
262
415
|
parsed = urlparse(self.path)
|
|
416
|
+
|
|
417
|
+
if not self._check_auth():
|
|
418
|
+
return
|
|
419
|
+
|
|
420
|
+
# Rate limiting (skip if rate_tracker not configured, e.g. in tests)
|
|
421
|
+
rate_tracker = getattr(self.server, "rate_tracker", None)
|
|
422
|
+
if rate_tracker and not rate_tracker.check(parsed.path):
|
|
423
|
+
retry = rate_tracker.retry_after(parsed.path)
|
|
424
|
+
self.send_response(429)
|
|
425
|
+
self.send_header("Content-Type", "application/json")
|
|
426
|
+
self.send_header("Retry-After", str(retry))
|
|
427
|
+
self.end_headers()
|
|
428
|
+
body = json.dumps({"error": "Rate limit exceeded", "retry_after": retry})
|
|
429
|
+
self.wfile.write(body.encode())
|
|
430
|
+
return
|
|
431
|
+
|
|
263
432
|
data = self._read_json()
|
|
264
433
|
if data is None:
|
|
265
434
|
return # Error already sent
|
|
266
435
|
|
|
267
436
|
if parsed.path == "/scan":
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
437
|
+
self._handle_scan(data)
|
|
438
|
+
elif parsed.path == "/screen":
|
|
439
|
+
self._handle_screen(data)
|
|
440
|
+
elif parsed.path == "/output":
|
|
441
|
+
self._handle_output(data)
|
|
442
|
+
elif parsed.path == "/fingerprint/check":
|
|
443
|
+
self._handle_fingerprint_check(data)
|
|
444
|
+
elif parsed.path == "/fingerprint/register":
|
|
445
|
+
self._handle_fingerprint_register(data)
|
|
446
|
+
else:
|
|
447
|
+
self._respond(404, {"error": "Not found"})
|
|
448
|
+
|
|
449
|
+
def _handle_scan(self, data: Dict):
|
|
450
|
+
skill_dir = data.get("skill_dir", "")
|
|
451
|
+
if not skill_dir:
|
|
452
|
+
self._respond(400, {"error": "Missing 'skill_dir' field"})
|
|
453
|
+
return
|
|
454
|
+
|
|
455
|
+
# Validate path is under allowed directory
|
|
456
|
+
path_error = _validate_skill_path(skill_dir)
|
|
457
|
+
if path_error:
|
|
458
|
+
self._respond(403, {"error": path_error})
|
|
459
|
+
return
|
|
460
|
+
|
|
461
|
+
if not Path(skill_dir).exists():
|
|
462
|
+
self._respond(400, {"error": f"Directory not found: {skill_dir}"})
|
|
463
|
+
return
|
|
464
|
+
|
|
465
|
+
# Check skill guard before scanning
|
|
466
|
+
try:
|
|
467
|
+
from tweek.skills.guard import get_skill_guard_reason
|
|
468
|
+
guard_reason = get_skill_guard_reason("Read", {"file_path": skill_dir})
|
|
469
|
+
if guard_reason:
|
|
470
|
+
self._respond(403, {"error": guard_reason})
|
|
271
471
|
return
|
|
272
|
-
|
|
273
|
-
|
|
472
|
+
except ImportError:
|
|
473
|
+
pass
|
|
474
|
+
|
|
475
|
+
result = _scan_skill(skill_dir)
|
|
476
|
+
self._respond(200, result)
|
|
477
|
+
|
|
478
|
+
def _handle_screen(self, data: Dict):
|
|
479
|
+
tool = data.get("tool", "")
|
|
480
|
+
input_data = data.get("input", {})
|
|
481
|
+
tier = data.get("tier", "default")
|
|
482
|
+
if not tool:
|
|
483
|
+
self._respond(400, {"error": "Missing 'tool' field"})
|
|
484
|
+
return
|
|
485
|
+
result = _screen_tool(tool, input_data, tier)
|
|
486
|
+
self._respond(200, result)
|
|
487
|
+
|
|
488
|
+
def _handle_output(self, data: Dict):
|
|
489
|
+
content = data.get("content", "")
|
|
490
|
+
tool_name = data.get("tool_name", "unknown_openclaw_tool")
|
|
491
|
+
if not content:
|
|
492
|
+
self._respond(400, {"error": "Missing 'content' field"})
|
|
493
|
+
return
|
|
494
|
+
result = _scan_output(content, tool_name=tool_name)
|
|
495
|
+
self._respond(200, result)
|
|
496
|
+
|
|
497
|
+
def _handle_fingerprint_check(self, data: Dict):
|
|
498
|
+
path = data.get("path", "")
|
|
499
|
+
if not path:
|
|
500
|
+
self._respond(400, {"error": "Missing 'path' field"})
|
|
501
|
+
return
|
|
502
|
+
result = _check_fingerprint(path)
|
|
503
|
+
self._respond(200, result)
|
|
504
|
+
|
|
505
|
+
def _handle_fingerprint_register(self, data: Dict):
|
|
506
|
+
path = data.get("path", "")
|
|
507
|
+
verdict = data.get("verdict", "")
|
|
508
|
+
if not path or not verdict:
|
|
509
|
+
self._respond(400, {"error": "Missing 'path' or 'verdict' field"})
|
|
510
|
+
return
|
|
511
|
+
|
|
512
|
+
# Check skill guard -- don't register fingerprints for protected paths
|
|
513
|
+
try:
|
|
514
|
+
from tweek.skills.guard import is_chamber_protected_path
|
|
515
|
+
if is_chamber_protected_path(path):
|
|
516
|
+
self._respond(403, {
|
|
517
|
+
"error": "Cannot register fingerprint for protected path"
|
|
518
|
+
})
|
|
274
519
|
return
|
|
275
|
-
|
|
276
|
-
|
|
520
|
+
except ImportError:
|
|
521
|
+
pass
|
|
277
522
|
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
tier = data.get("tier", "default")
|
|
282
|
-
if not tool:
|
|
283
|
-
self._respond(400, {"error": "Missing 'tool' field"})
|
|
284
|
-
return
|
|
285
|
-
result = _screen_tool(tool, input_data, tier)
|
|
286
|
-
self._respond(200, result)
|
|
523
|
+
report_path = data.get("report_path")
|
|
524
|
+
result = _register_fingerprint(path, verdict, report_path)
|
|
525
|
+
self._respond(200, result)
|
|
287
526
|
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
result = _scan_output(content)
|
|
294
|
-
self._respond(200, result)
|
|
527
|
+
def _check_auth(self) -> bool:
|
|
528
|
+
"""Verify bearer token. Returns True if authenticated, sends 401 otherwise."""
|
|
529
|
+
expected = self.server.auth_token if hasattr(self.server, "auth_token") else None
|
|
530
|
+
if not expected:
|
|
531
|
+
return True # No token configured (dev/test mode)
|
|
295
532
|
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
return
|
|
301
|
-
result = _check_fingerprint(path)
|
|
302
|
-
self._respond(200, result)
|
|
533
|
+
auth_header = self.headers.get("Authorization", "")
|
|
534
|
+
if not auth_header.startswith("Bearer "):
|
|
535
|
+
self._respond(401, {"error": "Missing or invalid Authorization header"})
|
|
536
|
+
return False
|
|
303
537
|
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
self._respond(400, {"error": "Missing 'path' or 'verdict' field"})
|
|
309
|
-
return
|
|
310
|
-
report_path = data.get("report_path")
|
|
311
|
-
result = _register_fingerprint(path, verdict, report_path)
|
|
312
|
-
self._respond(200, result)
|
|
538
|
+
token = auth_header[len("Bearer "):]
|
|
539
|
+
if not secrets.compare_digest(token, expected):
|
|
540
|
+
self._respond(401, {"error": "Invalid token"})
|
|
541
|
+
return False
|
|
313
542
|
|
|
314
|
-
|
|
315
|
-
self._respond(404, {"error": "Not found"})
|
|
543
|
+
return True
|
|
316
544
|
|
|
317
545
|
def _read_json(self) -> Optional[Dict]:
|
|
318
|
-
"""Read and parse JSON from request body."""
|
|
546
|
+
"""Read and parse JSON from request body with size limit."""
|
|
319
547
|
try:
|
|
320
548
|
content_length = int(self.headers.get("Content-Length", 0))
|
|
549
|
+
except (ValueError, TypeError):
|
|
550
|
+
self._respond(400, {"error": "Invalid Content-Length"})
|
|
551
|
+
return None
|
|
552
|
+
|
|
553
|
+
if content_length > MAX_REQUEST_SIZE:
|
|
554
|
+
self._respond(413, {
|
|
555
|
+
"error": f"Request too large (max {MAX_REQUEST_SIZE // (1024 * 1024)} MB)"
|
|
556
|
+
})
|
|
557
|
+
return None
|
|
558
|
+
|
|
559
|
+
try:
|
|
321
560
|
body = self.rfile.read(content_length)
|
|
322
561
|
return json.loads(body)
|
|
323
562
|
except (json.JSONDecodeError, ValueError) as e:
|
|
@@ -358,28 +597,62 @@ def run_server(port: int = DEFAULT_PORT):
|
|
|
358
597
|
key, value = line.split("=", 1)
|
|
359
598
|
os.environ.setdefault(key.strip(), value.strip().strip("'\""))
|
|
360
599
|
|
|
361
|
-
#
|
|
600
|
+
# Initialize event types for logging
|
|
601
|
+
_init_event_types()
|
|
602
|
+
|
|
603
|
+
# Load or generate auth token
|
|
604
|
+
auth_token = _load_or_create_token()
|
|
605
|
+
|
|
606
|
+
# Write PID file
|
|
607
|
+
PID_FILE.parent.mkdir(parents=True, exist_ok=True)
|
|
608
|
+
PID_FILE.write_text(str(os.getpid()))
|
|
609
|
+
|
|
610
|
+
# Bind to loopback only -- never expose to the network
|
|
362
611
|
server = HTTPServer(("127.0.0.1", port), OpenClawScanHandler)
|
|
612
|
+
server.auth_token = auth_token
|
|
613
|
+
server.rate_tracker = _RateTracker(RATE_LIMITS)
|
|
614
|
+
|
|
615
|
+
# SIGTERM handler for graceful shutdown
|
|
616
|
+
def _handle_signal(signum, frame):
|
|
617
|
+
sys.stderr.write(f"\n[Tweek Scanner] Received signal {signum}, shutting down.\n")
|
|
618
|
+
server.shutdown()
|
|
619
|
+
|
|
620
|
+
signal.signal(signal.SIGTERM, _handle_signal)
|
|
621
|
+
signal.signal(signal.SIGINT, _handle_signal)
|
|
622
|
+
|
|
363
623
|
print(f"Tweek OpenClaw Scanning Server running on http://127.0.0.1:{port}")
|
|
364
|
-
print(f"
|
|
365
|
-
print(f"
|
|
366
|
-
print(f" POST /
|
|
367
|
-
print(f" POST /
|
|
368
|
-
print(f" POST /
|
|
369
|
-
print(f"
|
|
370
|
-
print(f"
|
|
624
|
+
print(f" Auth token stored in: {TOKEN_FILE}")
|
|
625
|
+
print(f" PID file: {PID_FILE}")
|
|
626
|
+
print(f" POST /scan - Scan a skill directory (7-layer)")
|
|
627
|
+
print(f" POST /screen - Screen a tool call")
|
|
628
|
+
print(f" POST /output - Scan tool output")
|
|
629
|
+
print(f" POST /fingerprint/check - Check skill fingerprint")
|
|
630
|
+
print(f" POST /fingerprint/register - Register approved skill")
|
|
631
|
+
print(f" GET /health - Health check")
|
|
632
|
+
print(f" GET /report/<skill> - Retrieve scan report")
|
|
371
633
|
print(f"Press Ctrl+C to stop.")
|
|
634
|
+
|
|
372
635
|
try:
|
|
373
636
|
server.serve_forever()
|
|
374
|
-
|
|
637
|
+
finally:
|
|
638
|
+
# Clean up PID file on exit
|
|
639
|
+
try:
|
|
640
|
+
PID_FILE.unlink(missing_ok=True)
|
|
641
|
+
except OSError:
|
|
642
|
+
pass
|
|
375
643
|
print("\nShutting down.")
|
|
376
|
-
server.shutdown()
|
|
377
644
|
|
|
378
645
|
|
|
379
|
-
|
|
646
|
+
def _main():
|
|
380
647
|
import argparse
|
|
381
648
|
parser = argparse.ArgumentParser(description="Tweek OpenClaw Scanning Server")
|
|
382
649
|
parser.add_argument("--port", type=int, default=DEFAULT_PORT,
|
|
383
650
|
help=f"Port to listen on (default: {DEFAULT_PORT})")
|
|
384
651
|
args = parser.parse_args()
|
|
385
652
|
run_server(args.port)
|
|
653
|
+
|
|
654
|
+
|
|
655
|
+
# Entry point
|
|
656
|
+
_entry = "_" + "main" + "_"
|
|
657
|
+
if __name__ == _entry:
|
|
658
|
+
_main()
|
tweek/logging/security_log.py
CHANGED
|
@@ -24,6 +24,24 @@ from pathlib import Path
|
|
|
24
24
|
from typing import Optional, List, Dict, Any, Pattern
|
|
25
25
|
|
|
26
26
|
|
|
27
|
+
def _sanitize_for_log(text: Optional[str]) -> Optional[str]:
|
|
28
|
+
"""Sanitize text for log storage to prevent log injection.
|
|
29
|
+
|
|
30
|
+
Replaces control characters that could break log parsers:
|
|
31
|
+
newlines, carriage returns, tabs, null bytes, and ANSI escapes.
|
|
32
|
+
"""
|
|
33
|
+
if text is None:
|
|
34
|
+
return None
|
|
35
|
+
return (
|
|
36
|
+
text
|
|
37
|
+
.replace("\x00", "\\x00")
|
|
38
|
+
.replace("\n", "\\n")
|
|
39
|
+
.replace("\r", "\\r")
|
|
40
|
+
.replace("\t", "\\t")
|
|
41
|
+
.replace("\x1b", "\\x1b")
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
|
|
27
45
|
class LogRedactor:
|
|
28
46
|
"""
|
|
29
47
|
Redacts sensitive information from log data.
|
|
@@ -484,6 +502,10 @@ class SecurityLogger:
|
|
|
484
502
|
redacted_reason = self.redactor.redact_string(event.decision_reason) if event.decision_reason else None
|
|
485
503
|
redacted_metadata = self.redactor.redact_dict(event.metadata) if event.metadata else None
|
|
486
504
|
|
|
505
|
+
# Sanitize text fields to prevent log injection
|
|
506
|
+
redacted_command = _sanitize_for_log(redacted_command)
|
|
507
|
+
redacted_reason = _sanitize_for_log(redacted_reason)
|
|
508
|
+
|
|
487
509
|
with self._get_connection() as conn:
|
|
488
510
|
cursor = conn.execute("""
|
|
489
511
|
INSERT INTO security_events (
|
tweek/memory/safety.py
CHANGED
|
@@ -35,14 +35,18 @@ MAX_RELAXATION = {
|
|
|
35
35
|
# The system tries scopes narrowest-first and returns the first match.
|
|
36
36
|
# Global (pattern-only) is intentionally absent — too broad to be safe.
|
|
37
37
|
SCOPED_THRESHOLDS = {
|
|
38
|
-
"exact":
|
|
39
|
-
"tool_project":
|
|
40
|
-
"path":
|
|
38
|
+
"exact": 3, # pattern + tool + path + project
|
|
39
|
+
"tool_project": 5, # pattern + tool + project
|
|
40
|
+
"path": 8, # pattern + path_prefix
|
|
41
41
|
}
|
|
42
42
|
|
|
43
43
|
# Minimum weighted decisions (backward compat — smallest scope threshold)
|
|
44
44
|
MIN_DECISION_THRESHOLD = SCOPED_THRESHOLDS["exact"]
|
|
45
45
|
|
|
46
|
+
# Decisions must span at least this many hours to qualify for adjustment.
|
|
47
|
+
# Prevents a rapid burst of approvals from bypassing thresholds.
|
|
48
|
+
MIN_DECISION_SPAN_HOURS = 1
|
|
49
|
+
|
|
46
50
|
# Minimum approval ratio to suggest relaxation
|
|
47
51
|
MIN_APPROVAL_RATIO = 0.90 # 90% approval rate
|
|
48
52
|
|