tweek 0.4.1__py3-none-any.whl → 0.4.3__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 +439 -105
- 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.3.dist-info}/METADATA +1 -1
- {tweek-0.4.1.dist-info → tweek-0.4.3.dist-info}/RECORD +37 -34
- {tweek-0.4.1.dist-info → tweek-0.4.3.dist-info}/WHEEL +0 -0
- {tweek-0.4.1.dist-info → tweek-0.4.3.dist-info}/entry_points.txt +0 -0
- {tweek-0.4.1.dist-info → tweek-0.4.3.dist-info}/licenses/LICENSE +0 -0
- {tweek-0.4.1.dist-info → tweek-0.4.3.dist-info}/licenses/NOTICE +0 -0
- {tweek-0.4.1.dist-info → tweek-0.4.3.dist-info}/top_level.txt +0 -0
tweek/hooks/break_glass.py
CHANGED
|
@@ -12,14 +12,34 @@ The AI agent cannot execute `tweek override` — it requires a
|
|
|
12
12
|
separate CLI invocation by a human operator.
|
|
13
13
|
"""
|
|
14
14
|
|
|
15
|
+
import fcntl
|
|
15
16
|
import json
|
|
16
17
|
import sys
|
|
18
|
+
from contextlib import contextmanager
|
|
17
19
|
from datetime import datetime, timezone, timedelta
|
|
18
20
|
from pathlib import Path
|
|
19
21
|
from typing import Dict, List, Optional
|
|
20
22
|
|
|
21
23
|
|
|
22
24
|
BREAK_GLASS_PATH = Path.home() / ".tweek" / "break_glass.json"
|
|
25
|
+
BREAK_GLASS_LOCK = Path.home() / ".tweek" / ".break_glass.lock"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@contextmanager
|
|
29
|
+
def _file_lock():
|
|
30
|
+
"""Acquire exclusive file lock for break-glass state read-modify-write.
|
|
31
|
+
|
|
32
|
+
Prevents race conditions when concurrent hook calls try to consume
|
|
33
|
+
the same single-use override simultaneously.
|
|
34
|
+
"""
|
|
35
|
+
BREAK_GLASS_LOCK.parent.mkdir(parents=True, exist_ok=True)
|
|
36
|
+
lock_fd = open(BREAK_GLASS_LOCK, "w")
|
|
37
|
+
try:
|
|
38
|
+
fcntl.flock(lock_fd, fcntl.LOCK_EX)
|
|
39
|
+
yield
|
|
40
|
+
finally:
|
|
41
|
+
fcntl.flock(lock_fd, fcntl.LOCK_UN)
|
|
42
|
+
lock_fd.close()
|
|
23
43
|
|
|
24
44
|
|
|
25
45
|
def _load_state() -> Dict:
|
|
@@ -62,26 +82,27 @@ def create_override(
|
|
|
62
82
|
Returns:
|
|
63
83
|
The created override dict.
|
|
64
84
|
"""
|
|
65
|
-
|
|
66
|
-
|
|
85
|
+
with _file_lock():
|
|
86
|
+
state = _load_state()
|
|
87
|
+
now = datetime.now(timezone.utc)
|
|
67
88
|
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
89
|
+
override = {
|
|
90
|
+
"pattern": pattern_name,
|
|
91
|
+
"mode": mode,
|
|
92
|
+
"reason": reason,
|
|
93
|
+
"created_at": now.isoformat(),
|
|
94
|
+
"expires_at": None,
|
|
95
|
+
"used": False,
|
|
96
|
+
"used_at": None,
|
|
97
|
+
}
|
|
77
98
|
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
99
|
+
if mode == "duration" and duration_minutes:
|
|
100
|
+
expires = now + timedelta(minutes=duration_minutes)
|
|
101
|
+
override["expires_at"] = expires.isoformat()
|
|
81
102
|
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
103
|
+
state["overrides"].append(override)
|
|
104
|
+
_save_state(state)
|
|
105
|
+
return override
|
|
85
106
|
|
|
86
107
|
|
|
87
108
|
def check_override(pattern_name: str) -> Optional[Dict]:
|
|
@@ -93,38 +114,39 @@ def check_override(pattern_name: str) -> Optional[Dict]:
|
|
|
93
114
|
|
|
94
115
|
Returns the override dict if valid, None otherwise.
|
|
95
116
|
"""
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
117
|
+
with _file_lock():
|
|
118
|
+
state = _load_state()
|
|
119
|
+
now = datetime.now(timezone.utc)
|
|
120
|
+
found = None
|
|
99
121
|
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
122
|
+
for override in state["overrides"]:
|
|
123
|
+
if override["pattern"] != pattern_name:
|
|
124
|
+
continue
|
|
103
125
|
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
126
|
+
# Skip already-consumed single-use overrides
|
|
127
|
+
if override["mode"] == "once" and override.get("used"):
|
|
128
|
+
continue
|
|
107
129
|
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
130
|
+
# Check expiry for duration-based overrides
|
|
131
|
+
if override.get("expires_at"):
|
|
132
|
+
try:
|
|
133
|
+
expires = datetime.fromisoformat(override["expires_at"])
|
|
134
|
+
if now > expires:
|
|
135
|
+
continue
|
|
136
|
+
except (ValueError, TypeError):
|
|
113
137
|
continue
|
|
114
|
-
except (ValueError, TypeError):
|
|
115
|
-
continue
|
|
116
138
|
|
|
117
|
-
|
|
118
|
-
|
|
139
|
+
found = override
|
|
140
|
+
break
|
|
119
141
|
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
142
|
+
if found:
|
|
143
|
+
# Consume single-use overrides
|
|
144
|
+
if found["mode"] == "once":
|
|
145
|
+
found["used"] = True
|
|
146
|
+
found["used_at"] = now.isoformat()
|
|
147
|
+
_save_state(state)
|
|
126
148
|
|
|
127
|
-
|
|
149
|
+
return found
|
|
128
150
|
|
|
129
151
|
|
|
130
152
|
def list_overrides() -> List[Dict]:
|
|
@@ -156,8 +178,9 @@ def list_active_overrides() -> List[Dict]:
|
|
|
156
178
|
|
|
157
179
|
def clear_overrides() -> int:
|
|
158
180
|
"""Remove all overrides. Returns count of removed overrides."""
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
181
|
+
with _file_lock():
|
|
182
|
+
state = _load_state()
|
|
183
|
+
count = len(state.get("overrides", []))
|
|
184
|
+
state["overrides"] = []
|
|
185
|
+
_save_state(state)
|
|
186
|
+
return count
|
tweek/hooks/overrides.py
CHANGED
|
@@ -272,10 +272,28 @@ def get_trust_mode(overrides: Optional[SecurityOverrides] = None) -> str:
|
|
|
272
272
|
|
|
273
273
|
Returns: "interactive" or "automated"
|
|
274
274
|
"""
|
|
275
|
+
# Valid trust level values
|
|
276
|
+
_valid_trust_levels = frozenset({"interactive", "automated"})
|
|
277
|
+
|
|
275
278
|
# 1. Explicit environment variable (highest priority)
|
|
276
279
|
env_trust = os.environ.get("TWEEK_TRUST_LEVEL", "").lower().strip()
|
|
277
|
-
if env_trust in
|
|
280
|
+
if env_trust in _valid_trust_levels:
|
|
281
|
+
# Check if locked by CI/admin (prevents override by untrusted code)
|
|
282
|
+
locked = os.environ.get("TWEEK_TRUST_LEVEL_LOCKED", "").lower().strip()
|
|
283
|
+
if locked in _valid_trust_levels:
|
|
284
|
+
import logging as _logging
|
|
285
|
+
_logging.getLogger("tweek.overrides").info(
|
|
286
|
+
"TWEEK_TRUST_LEVEL_LOCKED=%s overrides TWEEK_TRUST_LEVEL=%s",
|
|
287
|
+
locked, env_trust,
|
|
288
|
+
)
|
|
289
|
+
return locked
|
|
278
290
|
return env_trust
|
|
291
|
+
elif env_trust:
|
|
292
|
+
import logging as _logging
|
|
293
|
+
_logging.getLogger("tweek.overrides").warning(
|
|
294
|
+
"Invalid TWEEK_TRUST_LEVEL=%r, ignoring. Valid: %s",
|
|
295
|
+
env_trust, _valid_trust_levels,
|
|
296
|
+
)
|
|
279
297
|
|
|
280
298
|
# 2. Parent process heuristic (macOS/Linux)
|
|
281
299
|
try:
|
tweek/hooks/post_tool_use.py
CHANGED
|
@@ -381,8 +381,12 @@ def process_hook(input_data: Dict[str, Any]) -> Dict[str, Any]:
|
|
|
381
381
|
session_id = input_data.get("session_id")
|
|
382
382
|
working_dir = input_data.get("cwd")
|
|
383
383
|
|
|
384
|
-
#
|
|
385
|
-
|
|
384
|
+
# Screen tools that return content worth analyzing.
|
|
385
|
+
# Includes content-bearing tools that could carry prompt injection.
|
|
386
|
+
screened_tools = {
|
|
387
|
+
"Read", "WebFetch", "Bash", "Grep", "WebSearch",
|
|
388
|
+
"Skill", "NotebookEdit", "Edit", "Write",
|
|
389
|
+
}
|
|
386
390
|
if tool_name not in screened_tools:
|
|
387
391
|
return {}
|
|
388
392
|
|
tweek/hooks/pre_tool_use.py
CHANGED
|
@@ -465,14 +465,31 @@ class TierManager:
|
|
|
465
465
|
|
|
466
466
|
for target_path_str in target_paths:
|
|
467
467
|
try:
|
|
468
|
-
|
|
468
|
+
target_path = Path(target_path_str).expanduser()
|
|
469
|
+
target_resolved = target_path.resolve()
|
|
469
470
|
except (OSError, ValueError):
|
|
470
471
|
continue
|
|
471
472
|
|
|
473
|
+
# Symlink detection: if the path or any parent is a symlink,
|
|
474
|
+
# log it and never relax the tier (fail-closed).
|
|
475
|
+
_is_symlink = False
|
|
476
|
+
try:
|
|
477
|
+
if target_path.is_symlink():
|
|
478
|
+
_is_symlink = True
|
|
479
|
+
else:
|
|
480
|
+
for parent in target_path.parents:
|
|
481
|
+
if parent.is_symlink():
|
|
482
|
+
_is_symlink = True
|
|
483
|
+
break
|
|
484
|
+
except (OSError, ValueError):
|
|
485
|
+
pass
|
|
486
|
+
|
|
472
487
|
# Check if path is inside the project
|
|
473
488
|
try:
|
|
474
489
|
target_resolved.relative_to(cwd_resolved)
|
|
475
|
-
|
|
490
|
+
if not _is_symlink:
|
|
491
|
+
continue # Inside project, not a symlink -- no escalation
|
|
492
|
+
# Symlink inside project pointing elsewhere — still check further
|
|
476
493
|
except ValueError:
|
|
477
494
|
pass # Outside project -- check further
|
|
478
495
|
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Tweek Self-Healing Post-Tool-Use Hook Wrapper
|
|
4
|
+
|
|
5
|
+
Deployed to ~/.tweek/hooks/ at install time. This wrapper script is
|
|
6
|
+
referenced by settings.json instead of the package-internal hook scripts.
|
|
7
|
+
|
|
8
|
+
Behavior:
|
|
9
|
+
1. Tries to import and run the real post_tool_use hook from the
|
|
10
|
+
installed tweek package.
|
|
11
|
+
2. If tweek has been uninstalled (ImportError), silently removes
|
|
12
|
+
all tweek hooks from settings.json and allows the tool response.
|
|
13
|
+
3. If the hook crashes for any other reason, allows the tool response
|
|
14
|
+
(fail-open to avoid blocking the user).
|
|
15
|
+
|
|
16
|
+
This file survives `pip uninstall tweek` because it lives outside the
|
|
17
|
+
Python package directory. It uses ONLY stdlib imports at module level.
|
|
18
|
+
"""
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import json
|
|
22
|
+
import sys
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
# ---------------------------------------------------------------------------
|
|
27
|
+
# Self-healing: remove tweek hooks from settings.json
|
|
28
|
+
# ---------------------------------------------------------------------------
|
|
29
|
+
|
|
30
|
+
def _remove_tweek_hooks_from_file(settings_path: Path) -> None:
|
|
31
|
+
"""Remove all tweek hook entries from a single settings.json file."""
|
|
32
|
+
if not settings_path.exists():
|
|
33
|
+
return
|
|
34
|
+
try:
|
|
35
|
+
with open(settings_path) as f:
|
|
36
|
+
settings = json.load(f)
|
|
37
|
+
except (json.JSONDecodeError, IOError, OSError):
|
|
38
|
+
return
|
|
39
|
+
|
|
40
|
+
hooks = settings.get("hooks", {})
|
|
41
|
+
if not hooks:
|
|
42
|
+
return
|
|
43
|
+
|
|
44
|
+
changed = False
|
|
45
|
+
for hook_type in list(hooks.keys()):
|
|
46
|
+
original = hooks[hook_type]
|
|
47
|
+
filtered = []
|
|
48
|
+
for hook_config in original:
|
|
49
|
+
original_inner = hook_config.get("hooks", [])
|
|
50
|
+
inner = [
|
|
51
|
+
h for h in original_inner
|
|
52
|
+
if "tweek" not in h.get("command", "").lower()
|
|
53
|
+
]
|
|
54
|
+
if len(inner) != len(original_inner):
|
|
55
|
+
changed = True
|
|
56
|
+
if inner:
|
|
57
|
+
hook_config["hooks"] = inner
|
|
58
|
+
filtered.append(hook_config)
|
|
59
|
+
if len(filtered) != len(original):
|
|
60
|
+
changed = True
|
|
61
|
+
if filtered:
|
|
62
|
+
hooks[hook_type] = filtered
|
|
63
|
+
else:
|
|
64
|
+
del hooks[hook_type]
|
|
65
|
+
|
|
66
|
+
if not changed:
|
|
67
|
+
return
|
|
68
|
+
|
|
69
|
+
if not hooks:
|
|
70
|
+
settings.pop("hooks", None)
|
|
71
|
+
|
|
72
|
+
try:
|
|
73
|
+
with open(settings_path, "w") as f:
|
|
74
|
+
json.dump(settings, f, indent=2)
|
|
75
|
+
except (IOError, OSError):
|
|
76
|
+
pass
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _self_heal() -> None:
|
|
80
|
+
"""Remove tweek hooks from all known settings.json locations and allow."""
|
|
81
|
+
# Clean global settings
|
|
82
|
+
_remove_tweek_hooks_from_file(
|
|
83
|
+
Path("~/.claude/settings.json").expanduser()
|
|
84
|
+
)
|
|
85
|
+
# Clean current project settings
|
|
86
|
+
_remove_tweek_hooks_from_file(
|
|
87
|
+
Path.cwd() / ".claude" / "settings.json"
|
|
88
|
+
)
|
|
89
|
+
# Clean any recorded project scopes
|
|
90
|
+
scopes_file = Path("~/.tweek/installed_scopes.json").expanduser()
|
|
91
|
+
if scopes_file.exists():
|
|
92
|
+
try:
|
|
93
|
+
scopes = json.loads(scopes_file.read_text()) or []
|
|
94
|
+
for scope_str in scopes:
|
|
95
|
+
_remove_tweek_hooks_from_file(
|
|
96
|
+
Path(scope_str) / "settings.json"
|
|
97
|
+
)
|
|
98
|
+
except (json.JSONDecodeError, IOError, OSError):
|
|
99
|
+
pass
|
|
100
|
+
|
|
101
|
+
# Output empty JSON to allow the tool response
|
|
102
|
+
print("{}")
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
# ---------------------------------------------------------------------------
|
|
106
|
+
# Main entry point
|
|
107
|
+
# ---------------------------------------------------------------------------
|
|
108
|
+
|
|
109
|
+
def main():
|
|
110
|
+
try:
|
|
111
|
+
from tweek.hooks.post_tool_use import main as real_main
|
|
112
|
+
real_main()
|
|
113
|
+
except ImportError:
|
|
114
|
+
_self_heal()
|
|
115
|
+
except Exception:
|
|
116
|
+
# Fail-open: if the hook crashes, allow the tool response
|
|
117
|
+
print("{}")
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
if __name__ == "__main__":
|
|
121
|
+
main()
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Tweek Self-Healing Pre-Tool-Use Hook Wrapper
|
|
4
|
+
|
|
5
|
+
Deployed to ~/.tweek/hooks/ at install time. This wrapper script is
|
|
6
|
+
referenced by settings.json instead of the package-internal hook scripts.
|
|
7
|
+
|
|
8
|
+
Behavior:
|
|
9
|
+
1. Tries to import and run the real pre_tool_use hook from the
|
|
10
|
+
installed tweek package.
|
|
11
|
+
2. If tweek has been uninstalled (ImportError), silently removes
|
|
12
|
+
all tweek hooks from settings.json and allows the tool call.
|
|
13
|
+
3. If the hook crashes for any other reason, allows the tool call
|
|
14
|
+
(fail-open to avoid blocking the user).
|
|
15
|
+
|
|
16
|
+
This file survives `pip uninstall tweek` because it lives outside the
|
|
17
|
+
Python package directory. It uses ONLY stdlib imports at module level.
|
|
18
|
+
"""
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import json
|
|
22
|
+
import sys
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
# ---------------------------------------------------------------------------
|
|
27
|
+
# Self-healing: remove tweek hooks from settings.json
|
|
28
|
+
# ---------------------------------------------------------------------------
|
|
29
|
+
|
|
30
|
+
def _remove_tweek_hooks_from_file(settings_path: Path) -> None:
|
|
31
|
+
"""Remove all tweek hook entries from a single settings.json file."""
|
|
32
|
+
if not settings_path.exists():
|
|
33
|
+
return
|
|
34
|
+
try:
|
|
35
|
+
with open(settings_path) as f:
|
|
36
|
+
settings = json.load(f)
|
|
37
|
+
except (json.JSONDecodeError, IOError, OSError):
|
|
38
|
+
return
|
|
39
|
+
|
|
40
|
+
hooks = settings.get("hooks", {})
|
|
41
|
+
if not hooks:
|
|
42
|
+
return
|
|
43
|
+
|
|
44
|
+
changed = False
|
|
45
|
+
for hook_type in list(hooks.keys()):
|
|
46
|
+
original = hooks[hook_type]
|
|
47
|
+
filtered = []
|
|
48
|
+
for hook_config in original:
|
|
49
|
+
original_inner = hook_config.get("hooks", [])
|
|
50
|
+
inner = [
|
|
51
|
+
h for h in original_inner
|
|
52
|
+
if "tweek" not in h.get("command", "").lower()
|
|
53
|
+
]
|
|
54
|
+
if len(inner) != len(original_inner):
|
|
55
|
+
changed = True
|
|
56
|
+
if inner:
|
|
57
|
+
hook_config["hooks"] = inner
|
|
58
|
+
filtered.append(hook_config)
|
|
59
|
+
if len(filtered) != len(original):
|
|
60
|
+
changed = True
|
|
61
|
+
if filtered:
|
|
62
|
+
hooks[hook_type] = filtered
|
|
63
|
+
else:
|
|
64
|
+
del hooks[hook_type]
|
|
65
|
+
|
|
66
|
+
if not changed:
|
|
67
|
+
return
|
|
68
|
+
|
|
69
|
+
if not hooks:
|
|
70
|
+
settings.pop("hooks", None)
|
|
71
|
+
|
|
72
|
+
try:
|
|
73
|
+
with open(settings_path, "w") as f:
|
|
74
|
+
json.dump(settings, f, indent=2)
|
|
75
|
+
except (IOError, OSError):
|
|
76
|
+
pass
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _self_heal() -> None:
|
|
80
|
+
"""Remove tweek hooks from all known settings.json locations and allow."""
|
|
81
|
+
# Clean global settings
|
|
82
|
+
_remove_tweek_hooks_from_file(
|
|
83
|
+
Path("~/.claude/settings.json").expanduser()
|
|
84
|
+
)
|
|
85
|
+
# Clean current project settings
|
|
86
|
+
_remove_tweek_hooks_from_file(
|
|
87
|
+
Path.cwd() / ".claude" / "settings.json"
|
|
88
|
+
)
|
|
89
|
+
# Clean any recorded project scopes
|
|
90
|
+
scopes_file = Path("~/.tweek/installed_scopes.json").expanduser()
|
|
91
|
+
if scopes_file.exists():
|
|
92
|
+
try:
|
|
93
|
+
scopes = json.loads(scopes_file.read_text()) or []
|
|
94
|
+
for scope_str in scopes:
|
|
95
|
+
_remove_tweek_hooks_from_file(
|
|
96
|
+
Path(scope_str) / "settings.json"
|
|
97
|
+
)
|
|
98
|
+
except (json.JSONDecodeError, IOError, OSError):
|
|
99
|
+
pass
|
|
100
|
+
|
|
101
|
+
# Output empty JSON to allow the tool call
|
|
102
|
+
print("{}")
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
# ---------------------------------------------------------------------------
|
|
106
|
+
# Main entry point
|
|
107
|
+
# ---------------------------------------------------------------------------
|
|
108
|
+
|
|
109
|
+
def main():
|
|
110
|
+
try:
|
|
111
|
+
from tweek.hooks.pre_tool_use import main as real_main
|
|
112
|
+
real_main()
|
|
113
|
+
except ImportError:
|
|
114
|
+
_self_heal()
|
|
115
|
+
except Exception:
|
|
116
|
+
# Fail-open: if the hook crashes, allow the tool call
|
|
117
|
+
print("{}")
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
if __name__ == "__main__":
|
|
121
|
+
main()
|
tweek/integrations/openclaw.py
CHANGED
|
@@ -7,17 +7,24 @@ the OpenClaw ecosystem.
|
|
|
7
7
|
"""
|
|
8
8
|
|
|
9
9
|
import json
|
|
10
|
+
import os
|
|
10
11
|
import subprocess
|
|
12
|
+
import tempfile
|
|
11
13
|
from dataclasses import dataclass, field
|
|
12
14
|
from pathlib import Path
|
|
13
15
|
from typing import Optional
|
|
14
16
|
|
|
17
|
+
from tweek.integrations.openclaw_detection import (
|
|
18
|
+
OPENCLAW_CONFIG,
|
|
19
|
+
OPENCLAW_DEFAULT_PORT,
|
|
20
|
+
OPENCLAW_HOME,
|
|
21
|
+
OPENCLAW_SKILLS_DIR,
|
|
22
|
+
check_gateway_active,
|
|
23
|
+
check_npm_installation,
|
|
24
|
+
check_running_process,
|
|
25
|
+
read_config_port,
|
|
26
|
+
)
|
|
15
27
|
|
|
16
|
-
# OpenClaw default paths and ports
|
|
17
|
-
OPENCLAW_DEFAULT_PORT = 18789
|
|
18
|
-
OPENCLAW_HOME = Path.home() / ".openclaw"
|
|
19
|
-
OPENCLAW_CONFIG = OPENCLAW_HOME / "openclaw.json"
|
|
20
|
-
OPENCLAW_SKILLS_DIR = OPENCLAW_HOME / "workspace" / "skills"
|
|
21
28
|
OPENCLAW_PLUGIN_NAME = "@tweek/openclaw-plugin"
|
|
22
29
|
|
|
23
30
|
# Scanning server port (separate from Tweek proxy port)
|
|
@@ -63,43 +70,21 @@ def detect_openclaw_installation() -> dict:
|
|
|
63
70
|
"skills_dir": None,
|
|
64
71
|
}
|
|
65
72
|
|
|
66
|
-
# Check npm global installation
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
text=True,
|
|
72
|
-
timeout=10,
|
|
73
|
-
)
|
|
74
|
-
if proc.returncode == 0:
|
|
75
|
-
data = json.loads(proc.stdout)
|
|
76
|
-
deps = data.get("dependencies", {})
|
|
77
|
-
if "openclaw" in deps:
|
|
78
|
-
info["installed"] = True
|
|
79
|
-
info["version"] = deps["openclaw"].get("version")
|
|
80
|
-
except (subprocess.TimeoutExpired, json.JSONDecodeError, FileNotFoundError):
|
|
81
|
-
pass
|
|
82
|
-
|
|
83
|
-
# Check which/where
|
|
84
|
-
if not info["installed"]:
|
|
85
|
-
try:
|
|
86
|
-
import os
|
|
87
|
-
cmd = ["which", "openclaw"] if os.name != "nt" else ["where", "openclaw"]
|
|
88
|
-
proc = subprocess.run(cmd, capture_output=True, text=True, timeout=5)
|
|
89
|
-
if proc.returncode == 0 and proc.stdout.strip():
|
|
90
|
-
info["installed"] = True
|
|
91
|
-
except (subprocess.TimeoutExpired, FileNotFoundError):
|
|
92
|
-
pass
|
|
73
|
+
# Check npm global installation (shared detection)
|
|
74
|
+
npm_info = check_npm_installation()
|
|
75
|
+
if npm_info:
|
|
76
|
+
info["installed"] = True
|
|
77
|
+
info["version"] = npm_info.get("version")
|
|
93
78
|
|
|
94
79
|
# Check for OpenClaw home directory
|
|
95
80
|
if OPENCLAW_HOME.exists():
|
|
96
81
|
info["installed"] = True
|
|
97
|
-
|
|
98
|
-
# Check for skills directory
|
|
99
82
|
if OPENCLAW_SKILLS_DIR.exists():
|
|
100
83
|
info["skills_dir"] = OPENCLAW_SKILLS_DIR
|
|
101
84
|
|
|
102
85
|
# Check for config file and extract port
|
|
86
|
+
# Note: uses local OPENCLAW_CONFIG reference (not shared module) so
|
|
87
|
+
# tests can patch tweek.integrations.openclaw.OPENCLAW_CONFIG
|
|
103
88
|
if OPENCLAW_CONFIG.exists():
|
|
104
89
|
info["config_path"] = OPENCLAW_CONFIG
|
|
105
90
|
try:
|
|
@@ -111,28 +96,13 @@ def detect_openclaw_installation() -> dict:
|
|
|
111
96
|
except (json.JSONDecodeError, IOError):
|
|
112
97
|
pass
|
|
113
98
|
|
|
114
|
-
# Check for running process
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
capture_output=True,
|
|
119
|
-
text=True,
|
|
120
|
-
timeout=5,
|
|
121
|
-
)
|
|
122
|
-
if proc.returncode == 0 and proc.stdout.strip():
|
|
123
|
-
info["process_running"] = True
|
|
124
|
-
except (subprocess.TimeoutExpired, FileNotFoundError):
|
|
125
|
-
pass
|
|
99
|
+
# Check for running process (shared detection)
|
|
100
|
+
process_info = check_running_process()
|
|
101
|
+
if process_info:
|
|
102
|
+
info["process_running"] = True
|
|
126
103
|
|
|
127
|
-
# Check if gateway port is active
|
|
128
|
-
|
|
129
|
-
try:
|
|
130
|
-
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
|
131
|
-
s.settimeout(1)
|
|
132
|
-
result = s.connect_ex(("127.0.0.1", info["gateway_port"]))
|
|
133
|
-
info["gateway_active"] = result == 0
|
|
134
|
-
except (socket.error, OSError):
|
|
135
|
-
pass
|
|
104
|
+
# Check if gateway port is active (shared detection)
|
|
105
|
+
info["gateway_active"] = check_gateway_active(info["gateway_port"])
|
|
136
106
|
|
|
137
107
|
return info
|
|
138
108
|
|
|
@@ -268,10 +238,16 @@ def _write_openclaw_config(
|
|
|
268
238
|
|
|
269
239
|
preset_config = preset_configs.get(preset, preset_configs["cautious"])
|
|
270
240
|
|
|
241
|
+
# Read scanner auth token if it exists
|
|
242
|
+
token_file = Path.home() / ".tweek" / ".scanner_token"
|
|
243
|
+
scanner_token = None
|
|
244
|
+
if token_file.exists():
|
|
245
|
+
scanner_token = token_file.read_text().strip()
|
|
246
|
+
|
|
271
247
|
# Merge into existing config
|
|
272
248
|
plugins = config.setdefault("plugins", {})
|
|
273
249
|
entries = plugins.setdefault("entries", {})
|
|
274
|
-
|
|
250
|
+
tweek_entry = {
|
|
275
251
|
"enabled": True,
|
|
276
252
|
"config": {
|
|
277
253
|
"preset": preset,
|
|
@@ -279,6 +255,9 @@ def _write_openclaw_config(
|
|
|
279
255
|
**preset_config,
|
|
280
256
|
},
|
|
281
257
|
}
|
|
258
|
+
if scanner_token:
|
|
259
|
+
tweek_entry["config"]["scannerToken"] = scanner_token
|
|
260
|
+
entries["tweek"] = tweek_entry
|
|
282
261
|
|
|
283
262
|
try:
|
|
284
263
|
OPENCLAW_HOME.mkdir(parents=True, exist_ok=True)
|
|
@@ -397,10 +376,28 @@ def setup_openclaw_protection(
|
|
|
397
376
|
"plugin_installed": result.plugin_installed,
|
|
398
377
|
}
|
|
399
378
|
|
|
379
|
+
# Sanitize preset for safe YAML interpolation
|
|
380
|
+
allowed_presets = {"paranoid", "cautious", "balanced", "trusted"}
|
|
381
|
+
safe_preset = preset if preset in allowed_presets else "cautious"
|
|
382
|
+
|
|
400
383
|
try:
|
|
384
|
+
# Write to temp file, then atomically replace
|
|
385
|
+
tweek_dir.mkdir(parents=True, exist_ok=True)
|
|
401
386
|
if yaml:
|
|
402
|
-
|
|
403
|
-
|
|
387
|
+
fd, tmp_path = tempfile.mkstemp(
|
|
388
|
+
dir=str(tweek_dir), suffix=".yaml.tmp"
|
|
389
|
+
)
|
|
390
|
+
try:
|
|
391
|
+
with os.fdopen(fd, "w") as f:
|
|
392
|
+
yaml.dump(tweek_config, f, default_flow_style=False)
|
|
393
|
+
os.replace(tmp_path, str(tweek_config_path))
|
|
394
|
+
except Exception:
|
|
395
|
+
# Clean up temp file on failure
|
|
396
|
+
try:
|
|
397
|
+
os.unlink(tmp_path)
|
|
398
|
+
except OSError:
|
|
399
|
+
pass
|
|
400
|
+
raise
|
|
404
401
|
else:
|
|
405
402
|
# Manual YAML writing as fallback
|
|
406
403
|
existing_lines = []
|
|
@@ -418,16 +415,29 @@ def setup_openclaw_protection(
|
|
|
418
415
|
existing_lines.append(line)
|
|
419
416
|
|
|
420
417
|
openclaw_lines = [
|
|
418
|
+
"# Generated by tweek",
|
|
421
419
|
"openclaw:",
|
|
422
420
|
" enabled: true",
|
|
423
421
|
f" gateway_port: {result.gateway_port}",
|
|
424
422
|
f" scanner_port: {result.scanner_port}",
|
|
425
|
-
f" preset: {
|
|
423
|
+
f" preset: {safe_preset}",
|
|
426
424
|
f" plugin_installed: {'true' if result.plugin_installed else 'false'}",
|
|
427
425
|
]
|
|
428
426
|
|
|
429
427
|
all_lines = existing_lines + openclaw_lines
|
|
430
|
-
|
|
428
|
+
fd, tmp_path = tempfile.mkstemp(
|
|
429
|
+
dir=str(tweek_dir), suffix=".yaml.tmp"
|
|
430
|
+
)
|
|
431
|
+
try:
|
|
432
|
+
with os.fdopen(fd, "w") as f:
|
|
433
|
+
f.write("\n".join(all_lines) + "\n")
|
|
434
|
+
os.replace(tmp_path, str(tweek_config_path))
|
|
435
|
+
except Exception:
|
|
436
|
+
try:
|
|
437
|
+
os.unlink(tmp_path)
|
|
438
|
+
except OSError:
|
|
439
|
+
pass
|
|
440
|
+
raise
|
|
431
441
|
except Exception as e:
|
|
432
442
|
result.warnings.append(f"Could not update Tweek config: {e}")
|
|
433
443
|
|