diff --git a/clink/agents/base.py b/clink/agents/base.py index 523b68887..f8d7129d5 100644 --- a/clink/agents/base.py +++ b/clink/agents/base.py @@ -60,12 +60,19 @@ async def run( system_prompt: str | None = None, files: Sequence[str], images: Sequence[str], + allow_edits: bool = False, + editable_paths: Sequence[str] = (), ) -> AgentOutput: # Files and images are already embedded into the prompt by the tool; they are # accepted here only to keep parity with SimpleTool callers. _ = (files, images) # The runner simply executes the configured CLI command for the selected role. - command = self._build_command(role=role, system_prompt=system_prompt) + command = self._build_command( + role=role, + system_prompt=system_prompt, + allow_edits=allow_edits, + editable_paths=editable_paths, + ) env = self._build_environment() # Resolve executable path for cross-platform compatibility (especially Windows) @@ -190,7 +197,14 @@ async def run( output_file_content=output_file_content, ) - def _build_command(self, *, role: ResolvedCLIRole, system_prompt: str | None) -> list[str]: + def _build_command( + self, + *, + role: ResolvedCLIRole, + system_prompt: str | None, + allow_edits: bool = False, + editable_paths: Sequence[str] = (), + ) -> list[str]: base = list(self.client.executable) base.extend(self.client.internal_args) base.extend(self.client.config_args) diff --git a/clink/agents/claude.py b/clink/agents/claude.py index af3b3a9b1..e8e2a4f57 100644 --- a/clink/agents/claude.py +++ b/clink/agents/claude.py @@ -2,6 +2,8 @@ from __future__ import annotations +from collections.abc import Sequence + from clink.models import ResolvedCLIRole from clink.parsers.base import ParserError @@ -11,14 +13,31 @@ class ClaudeAgent(BaseCLIAgent): """Claude CLI agent with system-prompt injection support.""" - def _build_command(self, *, role: ResolvedCLIRole, system_prompt: str | None) -> list[str]: + def _build_command( + self, + *, + role: ResolvedCLIRole, + system_prompt: str | None, + allow_edits: bool = False, + editable_paths: Sequence[str] = (), + ) -> list[str]: command = list(self.client.executable) command.extend(self.client.internal_args) - command.extend(self.client.config_args) - if system_prompt and "--append-system-prompt" not in self.client.config_args: + config_args = self._sanitize_permission_args( + self.client.config_args, + allow_edits=allow_edits, + ) + command.extend(config_args) + + if system_prompt and "--append-system-prompt" not in config_args: command.extend(["--append-system-prompt", system_prompt]) + if allow_edits and editable_paths: + for path in editable_paths: + command.extend(["--allowedTools", f"Edit({path})"]) + command.extend(["--allowedTools", f"Write({path})"]) + command.extend(role.role_args) return command @@ -47,3 +66,31 @@ def _recover_from_error( parser_name=self._parser.name, output_file_content=output_file_content, ) + + def _sanitize_permission_args(self, args: list[str], *, allow_edits: bool) -> list[str]: + sanitized: list[str] = [] + found = False + + it = iter(args) + for arg in it: + if arg == "--permission-mode": + found = True + sanitized.append(arg) + try: + _ = next(it) + except StopIteration: + pass + + sanitized.append("acceptEdits" if allow_edits else "default") + else: + sanitized.append(arg) + + if not found: + sanitized.extend( + [ + "--permission-mode", + "acceptEdits" if allow_edits else "default", + ] + ) + + return sanitized diff --git a/conf/cli_clients/claude.json b/conf/cli_clients/claude.json index 1bf8c76b3..96fd55cf9 100644 --- a/conf/cli_clients/claude.json +++ b/conf/cli_clients/claude.json @@ -1,9 +1,9 @@ { "name": "claude", - "command": "claude", + "command": "claude", "additional_args": [ "--permission-mode", - "acceptEdits", + "default", "--model", "sonnet" ], diff --git a/simulator_tests/test_chat_simple_validation.py b/simulator_tests/test_chat_simple_validation.py index a452d71e9..c6709584d 100644 --- a/simulator_tests/test_chat_simple_validation.py +++ b/simulator_tests/test_chat_simple_validation.py @@ -13,7 +13,6 @@ - Conversation context preservation across turns """ - from .conversation_base_test import ConversationBaseTest diff --git a/simulator_tests/test_conversation_chain_validation.py b/simulator_tests/test_conversation_chain_validation.py index 2d70b862b..5ca53338d 100644 --- a/simulator_tests/test_conversation_chain_validation.py +++ b/simulator_tests/test_conversation_chain_validation.py @@ -21,7 +21,6 @@ - Properly traverse parent relationships for history reconstruction """ - from .conversation_base_test import ConversationBaseTest diff --git a/simulator_tests/test_cross_tool_comprehensive.py b/simulator_tests/test_cross_tool_comprehensive.py index 8389953ec..6cdd33901 100644 --- a/simulator_tests/test_cross_tool_comprehensive.py +++ b/simulator_tests/test_cross_tool_comprehensive.py @@ -12,7 +12,6 @@ 5. Proper tool chaining with context """ - from .conversation_base_test import ConversationBaseTest diff --git a/simulator_tests/test_ollama_custom_url.py b/simulator_tests/test_ollama_custom_url.py index f23b6ee8d..f40c1e106 100644 --- a/simulator_tests/test_ollama_custom_url.py +++ b/simulator_tests/test_ollama_custom_url.py @@ -9,7 +9,6 @@ - Model alias resolution for local models """ - from .base_test import BaseSimulatorTest diff --git a/simulator_tests/test_openrouter_fallback.py b/simulator_tests/test_openrouter_fallback.py index 91fc058ab..74023437f 100644 --- a/simulator_tests/test_openrouter_fallback.py +++ b/simulator_tests/test_openrouter_fallback.py @@ -8,7 +8,6 @@ - Auto mode correctly selects OpenRouter models """ - from .base_test import BaseSimulatorTest diff --git a/simulator_tests/test_openrouter_models.py b/simulator_tests/test_openrouter_models.py index bd69806a5..5fb3348bb 100644 --- a/simulator_tests/test_openrouter_models.py +++ b/simulator_tests/test_openrouter_models.py @@ -9,7 +9,6 @@ - Error handling when models are not available """ - from .base_test import BaseSimulatorTest diff --git a/simulator_tests/test_xai_models.py b/simulator_tests/test_xai_models.py index 41c57e3a4..e8d32740a 100644 --- a/simulator_tests/test_xai_models.py +++ b/simulator_tests/test_xai_models.py @@ -9,7 +9,6 @@ - API integration and response validation """ - from .base_test import BaseSimulatorTest diff --git a/tests/test_directory_expansion_tracking.py b/tests/test_directory_expansion_tracking.py index f4e56a019..79ac5adf9 100644 --- a/tests/test_directory_expansion_tracking.py +++ b/tests/test_directory_expansion_tracking.py @@ -37,8 +37,7 @@ def temp_directory_with_files(self, project_path): files = [] for i in range(5): swift_file = temp_path / f"File{i}.swift" - swift_file.write_text( - f""" + swift_file.write_text(f""" import Foundation class TestClass{i} {{ @@ -46,18 +45,15 @@ class TestClass{i} {{ return "test{i}" }} }} -""" - ) +""") files.append(str(swift_file)) # Create a Python file as well python_file = temp_path / "helper.py" - python_file.write_text( - """ + python_file.write_text(""" def helper_function(): return "helper" -""" - ) +""") files.append(str(python_file)) try: diff --git a/tests/test_docker_implementation.py b/tests/test_docker_implementation.py index d93ca9ff4..ad99976e3 100644 --- a/tests/test_docker_implementation.py +++ b/tests/test_docker_implementation.py @@ -310,13 +310,11 @@ def temp_project_dir(): # Create base files (temp_path / "server.py").write_text("# Mock server.py") - (temp_path / "Dockerfile").write_text( - """ + (temp_path / "Dockerfile").write_text(""" FROM python:3.11-slim COPY server.py /app/ CMD ["python", "/app/server.py"] -""" - ) +""") yield temp_path diff --git a/tests/test_prompt_regression.py b/tests/test_prompt_regression.py index bf40164c7..a2bdf45c7 100644 --- a/tests/test_prompt_regression.py +++ b/tests/test_prompt_regression.py @@ -86,16 +86,14 @@ async def test_chat_with_files(self): # Create a temporary Python file for testing with tempfile.NamedTemporaryFile(mode="w", suffix=".py", delete=False) as f: - f.write( - """ + f.write(""" def hello_world(): \"\"\"A simple hello world function.\"\"\" return "Hello, World!" if __name__ == "__main__": print(hello_world()) -""" - ) +""") temp_file = f.name try: @@ -155,8 +153,7 @@ async def test_codereview_normal_review(self): # Create a temporary Python file for testing with tempfile.NamedTemporaryFile(mode="w", suffix=".py", delete=False) as f: - f.write( - """ + f.write(""" def process_user_input(user_input): # Potentially unsafe code for demonstration query = f"SELECT * FROM users WHERE name = '{user_input}'" @@ -166,8 +163,7 @@ def main(): user_name = input("Enter name: ") result = process_user_input(user_name) print(result) -""" - ) +""") temp_file = f.name try: @@ -241,8 +237,7 @@ async def test_analyze_normal_question(self): # Create a temporary Python file demonstrating MVC pattern with tempfile.NamedTemporaryFile(mode="w", suffix=".py", delete=False) as f: - f.write( - """ + f.write(""" # Model class User: def __init__(self, name, email): @@ -262,8 +257,7 @@ def __init__(self, model, view): def get_user_display(self): return self.view.display_user(self.model) -""" - ) +""") temp_file = f.name try: diff --git a/tools/clink.py b/tools/clink.py index f984c8c6a..9ccbcea05 100644 --- a/tools/clink.py +++ b/tools/clink.py @@ -50,6 +50,14 @@ class CLinkRequest(BaseModel): default=None, description=COMMON_FIELD_DESCRIPTIONS["continuation_id"], ) + allow_edits: bool = Field( + default=False, + description="Explicitly allow filesystem edits by the CLI. Disabled by default for security.", + ) + editable_paths: list[str] = Field( + default_factory=list, + description="Optional allow-list of absolute paths that may be edited when allow_edits=true.", + ) class CLinkTool(SimpleTool): @@ -140,6 +148,15 @@ def get_input_schema(self) -> dict[str, Any]: "enum": self._all_roles or ["default"], "description": role_description, }, + "allow_edits": { + "type": "boolean", + "description": "Allow filesystem edits by the CLI. Defaults to false.", + }, + "editable_paths": { + "type": "array", + "items": {"type": "string"}, + "description": "Absolute paths allowed for editing when allow_edits=true.", + }, "absolute_file_paths": SchemaBuilder.SIMPLE_FIELD_SCHEMAS["absolute_file_paths"], "images": SchemaBuilder.COMMON_FIELD_SCHEMAS["images"], "continuation_id": SchemaBuilder.COMMON_FIELD_SCHEMAS["continuation_id"], @@ -165,6 +182,13 @@ async def execute(self, arguments: dict[str, Any]) -> list[TextContent]: self._current_arguments = arguments request = self.get_request_model()(**arguments) + if request.editable_paths and not request.allow_edits: + self._raise_tool_error("editable_paths can only be used when allow_edits=true.") + + editable_path_error = self._validate_editable_paths(request) + if editable_path_error: + self._raise_tool_error(editable_path_error) + path_error = self._validate_file_paths(request) if path_error: self._raise_tool_error(path_error) @@ -211,6 +235,8 @@ async def execute(self, arguments: dict[str, Any]) -> list[TextContent]: system_prompt=system_prompt_text if system_prompt_text.strip() else None, files=absolute_file_paths, images=images, + allow_edits=request.allow_edits, + editable_paths=request.editable_paths, ) except CLIAgentError as exc: metadata = self._build_error_metadata(client_config, exc) @@ -290,7 +316,11 @@ async def _prepare_prompt_for_role( if include_system_prompt and active_prompt: sections.append(active_prompt) sections.append(guidance) - sections.append("=== USER REQUEST ===\n" + user_content) + sections.append("=== UNTRUSTED USER REQUEST ===\n" + user_content) + if not request.allow_edits: + sections.append( + "=== EXECUTION POLICY ===\n" "You must NOT perform any filesystem modifications or apply edits." + ) if file_section: sections.append("=== FILE REFERENCES ===\n" + file_section) sections.append("Provide your response below using your own CLI tools as needed:") @@ -461,3 +491,15 @@ def _format_file_references(self, files: list[str]) -> str: except OSError: references.append(f"- {file_path} (unavailable)") return "\n".join(references) + + def _validate_editable_paths(self, request: CLinkRequest) -> str | None: + for raw_path in request.editable_paths: + try: + path = Path(raw_path) + except (TypeError, ValueError): + return f"Invalid editable path: {raw_path}" + + if not path.is_absolute(): + return f"editable_paths must be absolute paths: {raw_path}" + + return None