Skip to content
Open
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 16 additions & 2 deletions clink/agents/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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] = (),
Comment on lines +205 to +206
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Enforce allow_edits for non-Claude runners

The new allow_edits/editable_paths controls are not enforced in BaseCLIAgent: _build_command still unconditionally forwards self.client.config_args. I checked conf/cli_clients/gemini.json and conf/cli_clients/codex.json, which include --yolo and --dangerously-bypass-approvals-and-sandbox; those write-capable flags will still be active even when allow_edits=false, so the “disabled by default” guarantee does not hold for these runners.

Useful? React with 👍 / 👎.

) -> list[str]:
base = list(self.client.executable)
base.extend(self.client.internal_args)
base.extend(self.client.config_args)
Expand Down
55 changes: 51 additions & 4 deletions clink/agents/claude.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,19 +6,36 @@
from clink.parsers.base import ParserError

from .base import AgentOutput, BaseCLIAgent

from collections.abc import Sequence

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

Expand Down Expand Up @@ -47,3 +64,33 @@ 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] = []
i = 0

while i < len(args):
arg = args[i]

if arg == "--permission-mode":
if i + 1 < len(args):
mode = args[i + 1]

if allow_edits:
sanitized.extend([arg, mode])
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Force acceptEdits when edit mode is requested

When allow_edits=true, _sanitize_permission_args keeps whatever --permission-mode value is already configured instead of switching to an edit-capable mode. Because this commit also changed conf/cli_clients/claude.json to --permission-mode default, an opt-in edit request still runs Claude in default mode, so write workflows that explicitly set allow_edits will not get the intended behavior. Override existing --permission-mode to acceptEdits whenever allow_edits is true.

Useful? React with 👍 / 👎.

else:
sanitized.extend([arg, "default"])

i += 2
continue

sanitized.append(arg)
i += 1

if "--permission-mode" not in sanitized:
sanitized.extend([
"--permission-mode",
"acceptEdits" if allow_edits else "default",
])

return sanitized
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

The current implementation of _sanitize_permission_args has a bug when "--permission-mode" is the last argument in the list. It will append the flag without its value, leading to a malformed command. Additionally, the while loop is complex and can be simplified using an iterator, which makes the logic easier to follow and less prone to off-by-one errors.

I suggest refactoring this method to use an iterator. This approach is more Pythonic and correctly handles all cases, including a dangling "--permission-mode" flag.

    def _sanitize_permission_args(self, args: list[str], *, allow_edits: bool) -> list[str]:
        sanitized: list[str] = []
        found_perm_mode = False
        args_iter = iter(args)
        for arg in args_iter:
            if arg == "--permission-mode":
                found_perm_mode = True
                sanitized.append(arg)
                try:
                    mode = next(args_iter)
                    if allow_edits:
                        sanitized.append(mode)
                    else:
                        sanitized.append("default")
                except StopIteration:
                    # Dangling flag, append a default value.
                    sanitized.append("acceptEdits" if allow_edits else "default")
            else:
                sanitized.append(arg)

        if not found_perm_mode:
            sanitized.extend([
                "--permission-mode",
                "acceptEdits" if allow_edits else "default",
            ])

        return sanitized

4 changes: 2 additions & 2 deletions conf/cli_clients/claude.json
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
{
"name": "claude",
"command": "claude",
"command": "claude",
"additional_args": [
"--permission-mode",
"acceptEdits",
"default",
"--model",
"sonnet"
],
Expand Down
45 changes: 44 additions & 1 deletion tools/clink.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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"],
Expand All @@ -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 requires 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)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -290,7 +316,12 @@ 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:")
Expand Down Expand Up @@ -461,3 +492,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 Exception:
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Catching a broad Exception can hide unexpected errors. It's better to catch more specific exceptions. pathlib.Path can raise TypeError if the input is not a string or bytes, or ValueError for certain invalid paths (e.g., containing null characters). Catching these specific exceptions makes the error handling more precise.

Suggested change
except Exception:
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
Loading