Skip to content

Commit feb77d2

Browse files
committed
feat: add safety level config (low/high)
- low (default): no restrictions, current behavior - high: file mutations (edit/delete/move/copy) restricted to CWD only; read-only commands and global tools (pip, python, git, etc.) still work New files: - iclaw/safety.py: check_exec_safety() and check_edit_safety() - tests/test_safety.py: 17 tests covering both levels Changes: - iclaw/config.py: safety_level in load/save_session_settings - iclaw/main.py: /safety command, /status shows level, enforcement in /cmd and agentic exec/edit tool dispatch
1 parent 773cde9 commit feb77d2

4 files changed

Lines changed: 470 additions & 8 deletions

File tree

iclaw/config.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ def load_session_settings() -> dict:
4848
"proxy": config.get("proxy"),
4949
"ca_bundle": config.get("ca_bundle"),
5050
"log_level": config.get("log_level", "verbose"),
51+
"safety_level": config.get("safety_level", "low"),
5152
}
5253

5354

@@ -59,6 +60,7 @@ def save_session_settings(
5960
proxy=None,
6061
ca_bundle=None,
6162
log_level="verbose",
63+
safety_level="low",
6264
) -> None:
6365
config = _load_config()
6466
config["model_provider"] = model_provider
@@ -67,4 +69,5 @@ def save_session_settings(
6769
config["proxy"] = proxy
6870
config["ca_bundle"] = ca_bundle
6971
config["log_level"] = log_level
72+
config["safety_level"] = safety_level
7073
_save_config(config)

iclaw/main.py

Lines changed: 53 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@
3535
from iclaw.exec_tool import exec_command as exec
3636
from iclaw.github_api import UnsupportedModelError, chat, get_copilot_token
3737
from iclaw.providers import openrouter
38+
from iclaw.safety import check_edit_safety, check_exec_safety
3839
from iclaw.tools.browser_tool import dispatch_browser_call
3940
from iclaw.tools.defs import TOOLS
4041
from iclaw.tools.edit_tool import EditTool
@@ -67,6 +68,7 @@
6768
("/copy", "Copy last Copilot response to clipboard"),
6869
("/read", "Print file contents to terminal (usage: /read <path>)"),
6970
("/cd", "Change directory and restart iclaw (usage: /cd <path>)"),
71+
("/safety", "Set safety level (usage: /safety [low|high])"),
7072
("/clear", "Clear conversation history"),
7173
("/compact", "Compact conversation history using LLM"),
7274
("/export", "Export full conversation history to JSON file"),
@@ -98,6 +100,7 @@ async def _main():
98100
proxy = settings["proxy"]
99101
ca_bundle = settings["ca_bundle"]
100102
log_level = settings["log_level"]
103+
safety_level = settings["safety_level"]
101104
log.set_level(
102105
{"info": log.INFO, "verbose": log.VERBOSE}.get(log_level, log.VERBOSE)
103106
)
@@ -186,6 +189,7 @@ async def _main():
186189
proxy=proxy,
187190
ca_bundle=ca_bundle,
188191
log_level=log_level,
192+
safety_level=safety_level,
189193
)
190194
continue
191195
if user_input == "/model":
@@ -199,6 +203,7 @@ async def _main():
199203
proxy=proxy,
200204
ca_bundle=ca_bundle,
201205
log_level=log_level,
206+
safety_level=safety_level,
202207
)
203208
continue
204209
if user_input.startswith("/search") or user_input == "/search":
@@ -267,6 +272,7 @@ async def _main():
267272
proxy=proxy,
268273
ca_bundle=ca_bundle,
269274
log_level=log_level,
275+
safety_level=safety_level,
270276
)
271277
continue
272278
if user_input == "/proxy" or user_input.startswith("/proxy "):
@@ -281,6 +287,7 @@ async def _main():
281287
proxy=proxy,
282288
ca_bundle=ca_bundle,
283289
log_level=log_level,
290+
safety_level=safety_level,
284291
)
285292
continue
286293
if user_input == "/ca_bundle" or user_input.startswith("/ca_bundle "):
@@ -295,6 +302,7 @@ async def _main():
295302
proxy=proxy,
296303
ca_bundle=ca_bundle,
297304
log_level=log_level,
305+
safety_level=safety_level,
298306
)
299307
continue
300308
if user_input == "/log" or user_input.startswith("/log "):
@@ -310,6 +318,7 @@ async def _main():
310318
proxy=proxy,
311319
ca_bundle=ca_bundle,
312320
log_level=log_level,
321+
safety_level=safety_level,
313322
)
314323
continue
315324
if user_input == "/status":
@@ -319,9 +328,29 @@ async def _main():
319328
print(f" proxy: {proxy or '(not set)'}")
320329
print(f" ca_bundle: {ca_bundle or '(system default)'}")
321330
print(f" log_level: {log_level}")
331+
print(f" safety_level: {safety_level}")
322332
print(f" cwd: {os.getcwd()}")
323333
print()
324334
continue
335+
if user_input == "/safety" or user_input.startswith("/safety "):
336+
parts = user_input.split(maxsplit=1)
337+
if len(parts) > 1 and parts[1].strip() in ("low", "high"):
338+
safety_level = parts[1].strip()
339+
save_session_settings(
340+
model_provider=model_provider,
341+
current_model=current_model,
342+
search_provider=search_provider,
343+
proxy=proxy,
344+
ca_bundle=ca_bundle,
345+
log_level=log_level,
346+
safety_level=safety_level,
347+
)
348+
print(f"Safety level set to: {safety_level}")
349+
else:
350+
print(f"Current safety level: {safety_level}")
351+
print("Usage: /safety low — no restrictions")
352+
print(" /safety high — file mutations restricted to CWD only")
353+
continue
325354
if user_input == "/cd" or user_input.startswith("/cd "):
326355
parts = user_input.split(maxsplit=1)
327356
target = (
@@ -360,8 +389,13 @@ async def _main():
360389
if len(parts) < 2 or not parts[1].strip():
361390
print("Usage: /cmd <command>", file=sys.stderr)
362391
else:
363-
output = exec(parts[1])
364-
print(output)
392+
cmd = parts[1]
393+
block_reason = check_exec_safety(cmd, safety_level)
394+
if block_reason:
395+
print(block_reason, file=sys.stderr)
396+
else:
397+
output = exec(cmd)
398+
print(output)
365399
continue
366400
if user_input == "/browse" or user_input.startswith("/browse "):
367401
parts = user_input.split(maxsplit=1)
@@ -491,7 +525,13 @@ async def _main():
491525
log.log_verbose(f"[tool] Result: ({len(search_context)} chars)")
492526

493527
if function_name == "exec":
494-
output = exec(function_args.get("command"))
528+
cmd = function_args.get("command")
529+
block_reason = check_exec_safety(cmd, safety_level)
530+
if block_reason:
531+
output = block_reason
532+
log.log_verbose(f"[safety] Blocked exec: {block_reason}")
533+
else:
534+
output = exec(cmd)
495535
tool_logs.append(
496536
{
497537
"timestamp": time.time(),
@@ -514,11 +554,16 @@ async def _main():
514554

515555
if function_name == "edit":
516556
file_path = function_args.get("file_path")
517-
result = EditTool.edit(
518-
file_path, function_args.get("edit_content")
519-
)
520-
with open(file_path, "w") as f:
521-
f.write(result)
557+
block_reason = check_edit_safety(file_path, safety_level)
558+
if block_reason:
559+
result = block_reason
560+
log.log_verbose(f"[safety] Blocked edit: {block_reason}")
561+
else:
562+
result = EditTool.edit(
563+
file_path, function_args.get("edit_content")
564+
)
565+
with open(file_path, "w") as f:
566+
f.write(result)
522567
tool_logs.append(
523568
{
524569
"timestamp": time.time(),

0 commit comments

Comments
 (0)