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.
Files changed (37) hide show
  1. tweek/__init__.py +1 -1
  2. tweek/cli_core.py +23 -6
  3. tweek/cli_install.py +361 -91
  4. tweek/cli_uninstall.py +119 -36
  5. tweek/config/families.yaml +13 -0
  6. tweek/config/models.py +31 -3
  7. tweek/config/patterns.yaml +126 -2
  8. tweek/diagnostics.py +124 -1
  9. tweek/hooks/break_glass.py +70 -47
  10. tweek/hooks/overrides.py +19 -1
  11. tweek/hooks/post_tool_use.py +6 -2
  12. tweek/hooks/pre_tool_use.py +19 -2
  13. tweek/hooks/wrapper_post_tool_use.py +121 -0
  14. tweek/hooks/wrapper_pre_tool_use.py +121 -0
  15. tweek/integrations/openclaw.py +70 -60
  16. tweek/integrations/openclaw_detection.py +140 -0
  17. tweek/integrations/openclaw_server.py +359 -86
  18. tweek/logging/security_log.py +22 -0
  19. tweek/memory/safety.py +7 -3
  20. tweek/memory/store.py +31 -10
  21. tweek/plugins/base.py +9 -1
  22. tweek/plugins/detectors/openclaw.py +31 -92
  23. tweek/plugins/screening/heuristic_scorer.py +12 -1
  24. tweek/plugins/screening/local_model_reviewer.py +9 -0
  25. tweek/security/language.py +2 -1
  26. tweek/security/llm_reviewer.py +45 -18
  27. tweek/security/local_model.py +21 -0
  28. tweek/security/model_registry.py +2 -2
  29. tweek/security/rate_limiter.py +99 -1
  30. tweek/skills/guard.py +30 -7
  31. {tweek-0.4.1.dist-info → tweek-0.4.2.dist-info}/METADATA +1 -1
  32. {tweek-0.4.1.dist-info → tweek-0.4.2.dist-info}/RECORD +37 -34
  33. {tweek-0.4.1.dist-info → tweek-0.4.2.dist-info}/WHEEL +0 -0
  34. {tweek-0.4.1.dist-info → tweek-0.4.2.dist-info}/entry_points.txt +0 -0
  35. {tweek-0.4.1.dist-info → tweek-0.4.2.dist-info}/licenses/LICENSE +0 -0
  36. {tweek-0.4.1.dist-info → tweek-0.4.2.dist-info}/licenses/NOTICE +0 -0
  37. {tweek-0.4.1.dist-info → tweek-0.4.2.dist-info}/top_level.txt +0 -0
@@ -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
- state = _load_state()
66
- now = datetime.now(timezone.utc)
85
+ with _file_lock():
86
+ state = _load_state()
87
+ now = datetime.now(timezone.utc)
67
88
 
68
- override = {
69
- "pattern": pattern_name,
70
- "mode": mode,
71
- "reason": reason,
72
- "created_at": now.isoformat(),
73
- "expires_at": None,
74
- "used": False,
75
- "used_at": None,
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
- if mode == "duration" and duration_minutes:
79
- expires = now + timedelta(minutes=duration_minutes)
80
- override["expires_at"] = expires.isoformat()
99
+ if mode == "duration" and duration_minutes:
100
+ expires = now + timedelta(minutes=duration_minutes)
101
+ override["expires_at"] = expires.isoformat()
81
102
 
82
- state["overrides"].append(override)
83
- _save_state(state)
84
- return override
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
- state = _load_state()
97
- now = datetime.now(timezone.utc)
98
- found = None
117
+ with _file_lock():
118
+ state = _load_state()
119
+ now = datetime.now(timezone.utc)
120
+ found = None
99
121
 
100
- for override in state["overrides"]:
101
- if override["pattern"] != pattern_name:
102
- continue
122
+ for override in state["overrides"]:
123
+ if override["pattern"] != pattern_name:
124
+ continue
103
125
 
104
- # Skip already-consumed single-use overrides
105
- if override["mode"] == "once" and override.get("used"):
106
- continue
126
+ # Skip already-consumed single-use overrides
127
+ if override["mode"] == "once" and override.get("used"):
128
+ continue
107
129
 
108
- # Check expiry for duration-based overrides
109
- if override.get("expires_at"):
110
- try:
111
- expires = datetime.fromisoformat(override["expires_at"])
112
- if now > expires:
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
- found = override
118
- break
139
+ found = override
140
+ break
119
141
 
120
- if found:
121
- # Consume single-use overrides
122
- if found["mode"] == "once":
123
- found["used"] = True
124
- found["used_at"] = now.isoformat()
125
- _save_state(state)
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
- return found
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
- state = _load_state()
160
- count = len(state.get("overrides", []))
161
- state["overrides"] = []
162
- _save_state(state)
163
- return count
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 ("interactive", "automated"):
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:
@@ -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
- # Only screen tools that return content worth analyzing
385
- screened_tools = {"Read", "WebFetch", "Bash", "Grep", "WebSearch"}
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
 
@@ -465,14 +465,31 @@ class TierManager:
465
465
 
466
466
  for target_path_str in target_paths:
467
467
  try:
468
- target_resolved = Path(target_path_str).expanduser().resolve()
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
- continue # Inside project -- no escalation
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()
@@ -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
- try:
68
- proc = subprocess.run(
69
- ["npm", "list", "-g", "openclaw", "--json"],
70
- capture_output=True,
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
- try:
116
- proc = subprocess.run(
117
- ["pgrep", "-f", "openclaw"],
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
- import socket
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
- entries["tweek"] = {
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
- with open(tweek_config_path, "w") as f:
403
- yaml.dump(tweek_config, f, default_flow_style=False)
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: {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
- tweek_config_path.write_text("\n".join(all_lines) + "\n")
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