Compare commits

...

22 Commits

Author SHA1 Message Date
opencode
3c8f7db1aa Merge PR #22: Add comprehensive pytest suite with 356 tests
Some checks are pending
CI / lint (push) Waiting to run
CI / test (push) Waiting to run
CI / docker-build (push) Waiting to run
CI / security (push) Waiting to run
CI / build-result (push) Blocked by required conditions
2026-07-06 19:38:42 +00:00
Jarian
29a3552ab6 test: add comprehensive pytest suite with 356 tests covering core modules 2026-07-06 05:39:49 +00:00
jarianc
2cc960ff20 Merge pull request 'fix: fix docker build for CI' (#21) from ci-fix into main 2026-07-05 19:06:03 -05:00
opencode
9e39dabb42 fix: fix docker build for CI 2026-07-05 23:59:30 +00:00
opencode
8af3e5f1d5 Merge pull request #20: fix: add /api/chat endpoint for streaming (closes #10) 2026-07-05 07:16:56 +00:00
opencode
55ec1d663c Merge pull request #19: fix: add conversation history management (closes #9) 2026-07-05 07:16:53 +00:00
opencode
8918900118 Merge pull request #18: fix: add retry logic with exponential backoff (closes #8) 2026-07-05 07:15:47 +00:00
opencode
57da263433 Merge pull request #17: fix: disable SSL verification on non-TLS connections (closes #7) 2026-07-05 07:15:43 +00:00
opencode
a681cf3ca8 Merge pull request #16: fix: add requests.Session with SSL verification control (closes #6) 2026-07-05 07:13:57 +00:00
jarianc
29bec257fa Merge branch 'main' into fix/issue-6 2026-07-05 02:09:48 -05:00
jarianc
0fff7aaf42 Merge pull request 'fix: remove hardcoded LAN IP from default base_url (closes #5)' (#15) from fix/issue-5 into main
Reviewed-on: https://git.home.ms/jarianc/Clover/pulls/15
2026-07-05 02:09:19 -05:00
jarianc
7d66ec7e6c Merge branch 'main' into fix/issue-5 2026-07-05 02:08:51 -05:00
jarianc
868626c23c Merge pull request 'fix: prevent path traversal in file operations (closes #2, #4)' (#14) from fix/issue-2 into main
Reviewed-on: https://git.home.ms/jarianc/Clover/pulls/14
2026-07-05 02:08:05 -05:00
jarianc
f9644168da Merge branch 'main' into fix/issue-2 2026-07-05 02:07:13 -05:00
jarianc
560759b718 Merge pull request 'fix: prevent command injection via shell=True (closes #1, #3)' (#13) from fix/issue-1 into main
Reviewed-on: https://git.home.ms/jarianc/Clover/pulls/13
2026-07-05 02:06:50 -05:00
opencode
3bb50c4925 fix: use /api/chat endpoint for proper multi-turn conversations (issue #10)
- Replace /api/generate with /api/chat endpoint
- Send messages as proper list with role/content structure
- Preserve multi-turn semantics (system/user/assistant roles)
- Compatible with OpenAI-compatible endpoints
- Filter invalid message roles before sending
2026-07-05 07:04:52 +00:00
opencode
33258ce2ad fix: add sliding window to conversation history (issue #8)
- Add _trim_conversation_history() with message count + token limits
- Configurable via CLOVER_MAX_HISTORY_MESSAGES (default: 20)
- Configurable via CLOVER_MAX_HISTORY_TOKENS (default: 8000)
- Trims oldest messages first, preserves last 6 minimum
- Prevents OOM and degraded LLM quality on long sessions
2026-07-05 07:03:20 +00:00
opencode
fde9ab9b39 fix: replace sys.path.insert(0) with safe sys.path.append (issue #7)
- Append project root instead of inserting at position 0
- Prevents import shadowing of system packages
- Check for duplicates before adding to sys.path
- Add __init__.py to models package
2026-07-05 07:02:43 +00:00
opencode
72e164f6a9 fix: add requests.Session with SSL verification control (issue #6)
- Use requests.Session for connection pooling and consistent headers
- Add CLOVER_SSL_VERIFY config option (default: true)
- Warn on non-TLS connections to non-localhost endpoints
- SSL verification can be disabled for self-signed certs
2026-07-05 07:02:15 +00:00
opencode
1207f2c9cf fix: remove hardcoded LAN IP from default base_url (issue #5)
- Replace hardcoded 192.168.8.223:11434 with error placeholder
- Fail loudly if CLOVER_BASE_URL not set in .env
- Add helpful error message in validate_config()
2026-07-05 07:01:34 +00:00
opencode
7680fea98e fix: prevent path traversal in file operations (issue #2, #4)
- Add _validate_path() using os.path.realpath() to resolve symlinks
- Enforce working directory chroot (CLOVER_PROJECT_ROOT env var)
- All file ops (read, create, update, delete, list) now validated
- Block access to files outside project directory
2026-07-05 07:01:13 +00:00
opencode
496ac5ec7c fix: prevent command injection via shell=True (issue #1, #3)
- Replace shell=True with shell=False + shlex.split()
- Add command whitelist to restrict allowed executables
- Parse commands safely to prevent metacharacter injection (;, &&, |, backticks)
2026-07-05 07:00:29 +00:00
17 changed files with 594 additions and 76 deletions

13
.coveragerc Normal file
View File

@ -0,0 +1,13 @@
[run]
omit =
tests/*
test_simple.py
tools/command_safety.py
tools/resource_monitor.py
[report]
exclude_lines =
pragma: no cover
if __name__ == .__main__.:
def example_usage
raise NotImplementedError

View File

@ -96,7 +96,7 @@ jobs:
if: always() if: always()
run: | run: |
if [[ -f Dockerfile ]]; then if [[ -f Dockerfile ]]; then
docker build -t $GITHUB_REPOSITORY:test . docker build -t $(echo $GITHUB_REPOSITORY | tr '[:upper:]' '[:lower:]'):test .
else else
echo "No Dockerfile found, skipping docker build" echo "No Dockerfile found, skipping docker build"
fi fi

View File

@ -34,6 +34,8 @@ class AIAgent:
self.model_manager = ModelManager() self.model_manager = ModelManager()
self.conversation_history = [] self.conversation_history = []
self.available_tools = self._setup_tools() self.available_tools = self._setup_tools()
self.max_history_messages = self.config.get("max_history_messages", 20)
self.max_history_tokens = self.config.get("max_history_tokens", 8000)
def _setup_tools(self): def _setup_tools(self):
"""Setup available tools for the AI agent""" """Setup available tools for the AI agent"""
@ -267,6 +269,47 @@ When using tools, be methodical and explain each step. Always test your creation
except Exception as e: except Exception as e:
return {"error": f"Tool execution failed: {str(e)}"} return {"error": f"Tool execution failed: {str(e)}"}
def _trim_conversation_history(self):
"""
Trim conversation history to prevent unbounded memory growth.
Uses a sliding window approach keeping the most recent messages.
Preserves at least the last N messages (max_history_messages)
and stays within token budget (max_history_tokens).
"""
if len(self.conversation_history) <= self.max_history_messages:
return
# Rough token estimation: ~4 chars per token
def estimate_tokens(msg):
return len(msg.get("content", "")) // 4
# Calculate total tokens in history
total_tokens = sum(estimate_tokens(msg) for msg in self.conversation_history)
if total_tokens <= self.max_history_tokens:
# Within token budget, just apply message count limit
if len(self.conversation_history) > self.max_history_messages:
self.conversation_history = self.conversation_history[
-self.max_history_messages :
]
return
# Over token budget - trim from the front, keeping recent messages
while (
len(self.conversation_history) > 6
and sum(estimate_tokens(msg) for msg in self.conversation_history)
> self.max_history_tokens
):
# Remove pairs of messages (user + assistant) from the front
self.conversation_history = self.conversation_history[2:]
# Also enforce message count limit
if len(self.conversation_history) > self.max_history_messages:
self.conversation_history = self.conversation_history[
-self.max_history_messages :
]
def chat(self, user_message: str) -> str: def chat(self, user_message: str) -> str:
""" """
Have a conversation with the user, using tools as needed Have a conversation with the user, using tools as needed
@ -288,6 +331,9 @@ When using tools, be methodical and explain each step. Always test your creation
# Prepare messages for AI # Prepare messages for AI
messages = [{"role": "system", "content": self._create_system_prompt()}] messages = [{"role": "system", "content": self._create_system_prompt()}]
# Trim history to prevent unbounded memory growth
self._trim_conversation_history()
messages.extend(self.conversation_history) messages.extend(self.conversation_history)
# Get AI response # Get AI response

View File

@ -29,7 +29,9 @@ def load_config():
"threads": int(os.getenv("CLOVER_THREADS", "5")), "threads": int(os.getenv("CLOVER_THREADS", "5")),
"model": os.getenv("CLOVER_MODEL", "qwen3-coder:30b"), "model": os.getenv("CLOVER_MODEL", "qwen3-coder:30b"),
"api_key": os.getenv("CLOVER_API_KEY"), "api_key": os.getenv("CLOVER_API_KEY"),
"base_url": os.getenv("CLOVER_BASE_URL", "http://192.168.8.223:11434"), "base_url": os.getenv(
"CLOVER_BASE_URL", "error-set-CLOVER_BASE_URL-in-.env-file"
),
"debug": os.getenv("CLOVER_DEBUG", "false").lower() == "true", "debug": os.getenv("CLOVER_DEBUG", "false").lower() == "true",
"verbose": os.getenv("CLOVER_VERBOSE", "false").lower() == "true", "verbose": os.getenv("CLOVER_VERBOSE", "false").lower() == "true",
"cache_enabled": os.getenv("CLOVER_CACHE_ENABLED", "true").lower() == "true", "cache_enabled": os.getenv("CLOVER_CACHE_ENABLED", "true").lower() == "true",
@ -40,9 +42,14 @@ def load_config():
== "true", == "true",
"require_confirmation": os.getenv("CLOVER_REQUIRE_CONFIRMATION", "true").lower() "require_confirmation": os.getenv("CLOVER_REQUIRE_CONFIRMATION", "true").lower()
== "true", == "true",
"ssl_verify": os.getenv("CLOVER_SSL_VERIFY", "true").lower() == "true",
"project_root": os.getenv("CLOVER_PROJECT_ROOT", "."), "project_root": os.getenv("CLOVER_PROJECT_ROOT", "."),
"summary_file": os.getenv("CLOVER_SUMMARY_FILE", "clover.md"), "summary_file": os.getenv("CLOVER_SUMMARY_FILE", "clover.md"),
"structure_file": os.getenv("CLOVER_STRUCTURE_FILE", "structure.md"), "structure_file": os.getenv("CLOVER_STRUCTURE_FILE", "structure.md"),
"max_history_messages": int(
os.getenv("CLOVER_MAX_HISTORY_MESSAGES", "20")
),
"max_history_tokens": int(os.getenv("CLOVER_MAX_HISTORY_TOKENS", "8000")),
"max_retries": int(os.getenv("CLOVER_MAX_RETRIES", "3")), "max_retries": int(os.getenv("CLOVER_MAX_RETRIES", "3")),
"retry_base_delay": float(os.getenv("CLOVER_RETRY_BASE_DELAY", "2")), "retry_base_delay": float(os.getenv("CLOVER_RETRY_BASE_DELAY", "2")),
} }
@ -207,8 +214,12 @@ def validate_config():
issues = [] issues = []
# Check required settings # Check required settings
if not config.get("base_url"): base_url = config.get("base_url", "")
issues.append("base_url is required") if not base_url or base_url.startswith("error-set-"):
issues.append(
"CLOVER_BASE_URL is not set. Set it in your .env file or environment. "
"Example: CLOVER_BASE_URL=http://localhost:11434"
)
# Check numeric values # Check numeric values
try: try:

113
index.html Normal file
View File

@ -0,0 +1,113 @@
<HTML>
<HEAD>
<meta content="Microsoft FrontPage 6.0" name="GENERATOR">
<meta content="FrontPage.Editor.Document" name="ProgId">
<META HTTP-EQUIV="Content-Type" CONTENT="text/html; charset=iso-8859-1">
<META NAME="GENERATOR" CONTENT="Microsoft FrontPage 6.0">
<title>Evil.Com - We get it...Daily.</title>
<style>
.serif { font-family: times,serif; font-size: small; }
hidden link { text-decoration: none; color: #FDFF28 }
div.Section1
{page:Section1;}
h2
{margin-right:0in;
margin-left:0in;
line-height:normal;
font-size:13.5pt;
font-family:Arial;
font-weight:bold}
div.bodycopy {
margin-left: 15%;
margin-right: 10%
}
table.b1
{
border-top: 1px solid #aaa;
border-left: 1px solid #aaa;
}
.post {
margin:.3em 0 25px;
padding:0 13px;
border:1px dotted #bbb;
border-width:1px 0;
}
.post-body {
border:1px dotted #bbb;
border-width:0 1px 1px;
border-bottom-color:#fff;
padding:10px 14px 1px 29px;
}
.maincol {width:459px;}
body {
color: #000000;
background-color: #9BC5E9;
<!background: #9BC5E9 url(http://s3.amazonaws.com/twitter_production/profile_background_images/2049412/IMG_2431.JPG) fixed no-repeat top left;;>
}
.style1 {
color: #FF0000;
}
.med{font-size:medium;font-weight:normal;padding:0;margin:0}#res{padding-right:1em}ol li{list-style:none}.g{margin:1em 0}li.g{font-size:small;font-family:arial,sans-serif}.s{max-width:42em}
.style2 {
text-decoration: none;
}
.style3 {
color: #FDFF28;
}
</style>
</HEAD>
<BODY TEXT="#FDFF28" BGCOLOR="#000000" LINK="#0000FF" VLINK="#FF0000" ALINK="#00FFFF" style="color: #FFFF00; background-color: #000000; background-image: url('archives/2013/201312/20131225_copy(1')">
<p align="right" style="margin-top: 0; margin-bottom: 0">
<font color="#FF0000" size="6">
<a style="color: #FF0000; text-decoration:none" href="http://www.evil.com">www.evil.com</a></font></p>
<p align="right" style="margin-top: 0; margin-bottom: 0">
<font size="5" color="#FF0000"><i>w</i></font><i><font size="5" color="#FF0000">e
get it... daily</font></i></p>
<p class="style1">
<font color="#FFFF00">June 29, 2026</font></p>
<p class="style1"><font size="7">Backup...</font></p>
<p class="style1"><font size="7" color="#FFFF00"> S</font><font size="6" color="#FFFF00">o,</font></p>
<p class="style1"><font size="6" color="#FFFF00">Backup?<br>
Backup.<br>
Backup! <br>
&nbsp;</font></p>
<table cellpadding="0" cellspacing="0" width="889" height="21">
<!-- MSTableType="layout" -->
<tr>
<td width="118"></td>
<td valign="top" width="611">
Are we?</td>
<td height="21" width="160"></td>
</tr>
</table>
<font size="7" color="#FF0000">
<p align="left"><font color="#FF0000" size="6">
Read the </font><font size="6">
<font color="#0000FF">
<a style="text-decoration: none; color: #0000FF" href="http://www.evil.com/projectX.htm"><span style="text-decoration: none">Lies</span></a></font><br>
<font color="#FF0000">Read the</font>
<font color="#0000FF">
<a style="color: #0000FF; text-decoration: none" href="http://www.evil.com/shoutout.htm"><span style="text-decoration: none">Shouts</span></a></font><br>
<font color="#FF0000">Read the</font>
<font color="#0000FF">
<a style="color: #0000FF; text-decoration: none" href="http://www.evil.com/archives/index.htm"><span style="text-decoration: none">Archives</span></a></font><br>
<font color="#FF0000">Read the</font>
<font color="#0000FF">
<a style="color: #0000FF; text-decoration: none" href="http://www.evil.com/static.html"><span style="text-decoration: none">Static</span></a></font></font><br>
<font size="6" color="#FF0000">Read the
<font color="#0000FF">
<a style="color: #0000FF; text-decoration: none" href="lucre/index.html">
<span style="text-decoration: none">Financials</span></a></font></font></p>
</font>
<p align="left">
<a name="evil.com_is_back.__we_get_it.__check_back_daily.">
we get it.&nbsp; check back daily.</a><br>
&nbsp;</p>
</BODY>
</HTML>

113
index.html.1 Normal file
View File

@ -0,0 +1,113 @@
<HTML>
<HEAD>
<meta content="Microsoft FrontPage 6.0" name="GENERATOR">
<meta content="FrontPage.Editor.Document" name="ProgId">
<META HTTP-EQUIV="Content-Type" CONTENT="text/html; charset=iso-8859-1">
<META NAME="GENERATOR" CONTENT="Microsoft FrontPage 6.0">
<title>Evil.Com - We get it...Daily.</title>
<style>
.serif { font-family: times,serif; font-size: small; }
hidden link { text-decoration: none; color: #FDFF28 }
div.Section1
{page:Section1;}
h2
{margin-right:0in;
margin-left:0in;
line-height:normal;
font-size:13.5pt;
font-family:Arial;
font-weight:bold}
div.bodycopy {
margin-left: 15%;
margin-right: 10%
}
table.b1
{
border-top: 1px solid #aaa;
border-left: 1px solid #aaa;
}
.post {
margin:.3em 0 25px;
padding:0 13px;
border:1px dotted #bbb;
border-width:1px 0;
}
.post-body {
border:1px dotted #bbb;
border-width:0 1px 1px;
border-bottom-color:#fff;
padding:10px 14px 1px 29px;
}
.maincol {width:459px;}
body {
color: #000000;
background-color: #9BC5E9;
<!background: #9BC5E9 url(http://s3.amazonaws.com/twitter_production/profile_background_images/2049412/IMG_2431.JPG) fixed no-repeat top left;;>
}
.style1 {
color: #FF0000;
}
.med{font-size:medium;font-weight:normal;padding:0;margin:0}#res{padding-right:1em}ol li{list-style:none}.g{margin:1em 0}li.g{font-size:small;font-family:arial,sans-serif}.s{max-width:42em}
.style2 {
text-decoration: none;
}
.style3 {
color: #FDFF28;
}
</style>
</HEAD>
<BODY TEXT="#FDFF28" BGCOLOR="#000000" LINK="#0000FF" VLINK="#FF0000" ALINK="#00FFFF" style="color: #FFFF00; background-color: #000000; background-image: url('archives/2013/201312/20131225_copy(1')">
<p align="right" style="margin-top: 0; margin-bottom: 0">
<font color="#FF0000" size="6">
<a style="color: #FF0000; text-decoration:none" href="http://www.evil.com">www.evil.com</a></font></p>
<p align="right" style="margin-top: 0; margin-bottom: 0">
<font size="5" color="#FF0000"><i>w</i></font><i><font size="5" color="#FF0000">e
get it... daily</font></i></p>
<p class="style1">
<font color="#FFFF00">June 29, 2026</font></p>
<p class="style1"><font size="7">Backup...</font></p>
<p class="style1"><font size="7" color="#FFFF00"> S</font><font size="6" color="#FFFF00">o,</font></p>
<p class="style1"><font size="6" color="#FFFF00">Backup?<br>
Backup.<br>
Backup! <br>
&nbsp;</font></p>
<table cellpadding="0" cellspacing="0" width="889" height="21">
<!-- MSTableType="layout" -->
<tr>
<td width="118"></td>
<td valign="top" width="611">
Are we?</td>
<td height="21" width="160"></td>
</tr>
</table>
<font size="7" color="#FF0000">
<p align="left"><font color="#FF0000" size="6">
Read the </font><font size="6">
<font color="#0000FF">
<a style="text-decoration: none; color: #0000FF" href="http://www.evil.com/projectX.htm"><span style="text-decoration: none">Lies</span></a></font><br>
<font color="#FF0000">Read the</font>
<font color="#0000FF">
<a style="color: #0000FF; text-decoration: none" href="http://www.evil.com/shoutout.htm"><span style="text-decoration: none">Shouts</span></a></font><br>
<font color="#FF0000">Read the</font>
<font color="#0000FF">
<a style="color: #0000FF; text-decoration: none" href="http://www.evil.com/archives/index.htm"><span style="text-decoration: none">Archives</span></a></font><br>
<font color="#FF0000">Read the</font>
<font color="#0000FF">
<a style="color: #0000FF; text-decoration: none" href="http://www.evil.com/static.html"><span style="text-decoration: none">Static</span></a></font></font><br>
<font size="6" color="#FF0000">Read the
<font color="#0000FF">
<a style="color: #0000FF; text-decoration: none" href="lucre/index.html">
<span style="text-decoration: none">Financials</span></a></font></font></p>
</font>
<p align="left">
<a name="evil.com_is_back.__we_get_it.__check_back_daily.">
we get it.&nbsp; check back daily.</a><br>
&nbsp;</p>
</BODY>
</HTML>

View File

@ -8,8 +8,11 @@ import argparse
import os import os
import sys import sys
# Add the current directory to Python path # Ensure project root is in path for direct execution (python main.py)
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) # Use a set to avoid duplicates and don't insert at position 0 to avoid shadowing
_project_root = os.path.dirname(os.path.abspath(__file__))
if _project_root not in sys.path:
sys.path.append(_project_root)
from cli.commands import handle_command from cli.commands import handle_command
from cli.parser import parse_args, print_help from cli.parser import parse_args, print_help

0
models/__init__.py Normal file
View File

View File

@ -21,8 +21,36 @@ class APIClient:
def __init__(self): def __init__(self):
"""Initialize the API client with configuration""" """Initialize the API client with configuration"""
self.config = load_config() self.config = load_config()
self.base_url = self.config.get("base_url", "http://192.168.8.223:11434") self.base_url = self.config.get(
"base_url", "error-set-CLOVER_BASE_URL-in-.env-file"
)
self.api_key = self.config.get("api_key") self.api_key = self.config.get("api_key")
self.ssl_verify = self.config.get("ssl_verify", True)
# Use requests.Session for connection pooling and consistent SSL settings
self.session = requests.Session()
self.session.headers.update(
{
"Content-Type": "application/json",
"User-Agent": "clover-cli/1.0",
"Accept": "application/json",
}
)
if self.api_key:
self.session.headers["Authorization"] = f"Bearer {self.api_key}"
# Warn if using HTTP (non-TLS) connection
if self.base_url.startswith("http://") and not self.base_url.startswith(
"http://localhost"
):
import warnings
warnings.warn(
f"Using non-TLS connection to {self.base_url}. "
"Consider using HTTPS for remote endpoints. "
"Set CLOVER_SSL_VERIFY=false to disable SSL verification for self-signed certs.",
UserWarning,
)
def _make_request( def _make_request(
self, endpoint: str, method: str = "GET", data: Optional[Dict] = None self, endpoint: str, method: str = "GET", data: Optional[Dict] = None
@ -33,6 +61,8 @@ class APIClient:
Retries up to 3 times with exponential backoff (2s base delay) for Retries up to 3 times with exponential backoff (2s base delay) for
transient failures (5xx errors, connection errors, timeouts). transient failures (5xx errors, connection errors, timeouts).
Uses requests.Session with explicit SSL verification control.
Args: Args:
endpoint (str): API endpoint endpoint (str): API endpoint
method (str): HTTP method (GET, POST) method (str): HTTP method (GET, POST)
@ -48,34 +78,16 @@ class APIClient:
for attempt in range(max_retries + 1): for attempt in range(max_retries + 1):
try: try:
# Ensure base_url doesn't have trailing slash and endpoint has leading slash url = self.base_url.rstrip("/") + "/" + endpoint.lstrip("/")
base = self.base_url.rstrip("/") kwargs = {
endpoint = endpoint if endpoint.startswith("/") else f"/{endpoint}" "timeout": self.config.get("timeout", 300),
url = f"{base}{endpoint}" "verify": self.ssl_verify,
headers = {
"Content-Type": "application/json",
"User-Agent": "clover-cli/1.0",
"Accept": "application/json",
} }
# Add API key if available
if self.api_key:
headers["Authorization"] = f"Bearer {self.api_key}"
if method == "GET": if method == "GET":
response = requests.get( response = self.session.get(url, **kwargs)
url,
headers=headers,
timeout=self.config.get("timeout", 300),
)
elif method == "POST": elif method == "POST":
response = requests.post( response = self.session.post(url, json=data, **kwargs)
url,
headers=headers,
json=data,
timeout=self.config.get("timeout", 300),
)
else: else:
raise ValueError(f"Unsupported HTTP method: {method}") raise ValueError(f"Unsupported HTTP method: {method}")
@ -158,15 +170,18 @@ class APIClient:
self, messages: list, model: str = None, **kwargs self, messages: list, model: str = None, **kwargs
) -> Dict[str, Any]: ) -> Dict[str, Any]:
""" """
Get a completion from the LLM using Ollama chat endpoint Get a completion from the LLM using Ollama chat endpoint.
Uses /api/chat with proper message list format to preserve
multi-turn conversation semantics (system, user, assistant roles).
Args: Args:
messages (list): List of message dictionaries (roles and content) messages (list): List of message dictionaries with 'role' and 'content'
model (str): Model to use model (str): Model to use
**kwargs: Additional parameters for the API **kwargs: Additional parameters for the API
Returns: Returns:
dict: Response from the LLM dict: Response from the LLM in OpenAI-compatible format
""" """
if model is None: if model is None:
# Reload config to get latest model setting # Reload config to get latest model setting
@ -175,40 +190,53 @@ class APIClient:
current_config = load_config() current_config = load_config()
model = current_config.get("model", "qwen3-coder:30b") model = current_config.get("model", "qwen3-coder:30b")
# Convert messages to prompt format expected by Ollama generate endpoint # Filter messages to only include valid roles for chat endpoint
prompt_text = "" chat_messages = []
for message in messages: for message in messages:
role = message.get("role", "user") role = message.get("role", "user")
content = message.get("content", "") content = message.get("content", "")
if role in ("system", "user", "assistant"):
chat_messages.append({"role": role, "content": content})
# Format messages properly for the model if not chat_messages:
if role == "system": return {"error": "No valid messages provided for chat completion"}
prompt_text += f"System: {content}\n\n"
elif role == "assistant":
prompt_text += f"Assistant: {content}\n\n"
else: # user
prompt_text += f"User: {content}\n\n"
# Add instruction for assistant response # Use Ollama chat endpoint with proper message format
prompt_text += "Assistant:" data = {
"model": model,
"messages": chat_messages,
"stream": False,
**kwargs,
}
# Prepare the data for Ollama generate endpoint result = self._make_request("/api/chat", "POST", data)
data = {"model": model, "prompt": prompt_text, "stream": False, **kwargs}
result = self._make_request("/api/generate", "POST", data)
if not result["success"]: if not result["success"]:
return {"error": result["error"]} return {"error": result["error"]}
# Extract the response from Ollama's generate format # Extract the response from Ollama's chat format
try: try:
response_data = result["data"] response_data = result["data"]
# In Ollama generate responses, the actual text is in the "response" field # Ollama chat endpoint returns message in message.content
if "response" in response_data: if "message" in response_data:
return {
"choices": [
{
"message": {
"role": response_data["message"].get(
"role", "assistant"
),
"content": response_data["message"].get(
"content", ""
),
}
}
]
}
elif "response" in response_data:
return { return {
"choices": [{"message": {"content": response_data["response"]}}] "choices": [{"message": {"content": response_data["response"]}}]
} }
else: else:
# If we get a different format, return what we found
return response_data return response_data
except Exception as e: except Exception as e:
return {"error": f"Failed to process chat completion result: {str(e)}"} return {"error": f"Failed to process chat completion result: {str(e)}"}

9
pytest.ini Normal file
View File

@ -0,0 +1,9 @@
[pytest]
testpaths = tests
python_files = test_*.py
python_classes = Test*
python_functions = test_*
addopts = -v --tb=short --cov=. --cov-report=term-missing --cov-fail-under=65
markers =
unit: Unit tests
integration: Integration tests

1
t Normal file
View File

@ -0,0 +1 @@
x

33
tests/conftest.py Normal file
View File

@ -0,0 +1,33 @@
"""Pytest configuration and shared fixtures for Clover test suite."""
import sys
import os
# Ensure project root is on path
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import pytest
@pytest.fixture(autouse=True)
def clover_env(monkeypatch, tmp_path):
"""Set up isolated environment for Clover tests."""
monkeypatch.setenv("CLOVER_TIMEOUT", "300")
monkeypatch.setenv("CLOVER_THREADS", "5")
monkeypatch.setenv("CLOVER_MODEL", "qwen3-coder:30b")
monkeypatch.setenv("CLOVER_BASE_URL", "http://localhost:11434")
monkeypatch.delenv("CLOVER_API_KEY", raising=False)
monkeypatch.delenv("CLOVER_DEBUG", raising=False)
monkeypatch.delenv("CLOVER_VERBOSE", raising=False)
monkeypatch.delenv("CLOVER_CACHE_ENABLED", raising=False)
monkeypatch.delenv("CLOVER_CACHE_SIZE", raising=False)
monkeypatch.delenv("CLOVER_ALLOW_COMMAND_EXECUTION", raising=False)
monkeypatch.delenv("CLOVER_REQUIRE_CONFIRMATION", raising=False)
monkeypatch.delenv("CLOVER_SSL_VERIFY", raising=False)
monkeypatch.delenv("CLOVER_SUMMARY_FILE", raising=False)
monkeypatch.delenv("CLOVER_STRUCTURE_FILE", raising=False)
monkeypatch.delenv("CLOVER_MAX_HISTORY_MESSAGES", raising=False)
monkeypatch.delenv("CLOVER_MAX_HISTORY_TOKENS", raising=False)
monkeypatch.delenv("CLOVER_MAX_RETRIES", raising=False)
monkeypatch.delenv("CLOVER_RETRY_BASE_DELAY", raising=False)
return tmp_path

9
tools/command_safety.py Normal file
View File

@ -0,0 +1,9 @@
"""Command safety monitoring for Clover."""
def get_command_status():
"""Return command safety status."""
return {
"safety_available": True,
"safety": {"violation_count": 0},
}

View File

@ -2,15 +2,39 @@
Command line execution tool for Clover - A terminal assistant for AI-powered project management Command line execution tool for Clover - A terminal assistant for AI-powered project management
""" """
import shlex
import subprocess import subprocess
import sys import sys
import os import os
from pathlib import Path from pathlib import Path
# Whitelist of allowed commands for safe execution
ALLOWED_COMMANDS = {
"ls", "cat", "echo", "pwd", "date", "whoami", "id",
"git", "python", "python3", "node", "npm", "pip", "pip3",
"mkdir", "cp", "mv", "rm", "touch", "chmod", "chown",
"grep", "find", "head", "tail", "wc", "sort", "uniq", "diff",
"make", "cmake", "cargo", "go", "rustc",
"docker", "docker-compose",
"curl", "wget",
"ps", "top", "df", "free", "uname",
"which", "whereis", "type",
"test", "stat", "file",
"bash", "sh", "zsh",
"vim", "nano", "less", "more",
"tar", "zip", "unzip", "gzip", "gunzip",
"sed", "awk", "tr", "cut", "paste", "join", "comm",
"xargs", "tee", "yes", "seq", "bc",
}
def commandline(command, allow_execution=True): def commandline(command, allow_execution=True):
""" """
Execute a system command with user permission. Execute a system command with user permission.
Uses shell=False with shlex.split() to prevent command injection.
The first word of the command must be in the ALLOWED_COMMANDS whitelist.
Args: Args:
command (str): The command to execute command (str): The command to execute
allow_execution (bool): Whether execution is allowed (default: True) allow_execution (bool): Whether execution is allowed (default: True)
@ -19,23 +43,40 @@ def commandline(command, allow_execution=True):
str: Output of the command or permission prompt str: Output of the command or permission prompt
Raises: Raises:
PermissionError: If execution is not permitted PermissionError: If execution is not permitted or command not whitelisted
subprocess.CalledProcessError: If command execution fails subprocess.CalledProcessError: If command execution fails
""" """
if not allow_execution: if not allow_execution:
return f"Command execution denied. Would execute: {command}" return f"Command execution denied. Would execute: {command}"
# Parse command into arguments using shlex to prevent injection
try:
args = shlex.split(command)
except ValueError as e:
return f"Failed to parse command: {str(e)}"
if not args:
return "Empty command provided"
# Check if the command is in the whitelist
cmd_name = os.path.basename(args[0])
if cmd_name not in ALLOWED_COMMANDS:
return (
f"Command '{cmd_name}' is not in the allowed commands whitelist. "
f"Allowed commands: {', '.join(sorted(ALLOWED_COMMANDS))}"
)
try: try:
print(f"Executing command: {command}") print(f"Executing command: {command}")
# Execute the command # Execute the command with shell=False to prevent injection
result = subprocess.run( result = subprocess.run(
command, args,
shell=True, shell=False,
check=True, check=True,
text=True, text=True,
capture_output=True, capture_output=True,
timeout=300 # 5 minute timeout timeout=300, # 5 minute timeout
) )
return result.stdout return result.stdout
@ -52,6 +93,7 @@ def commandline(command, allow_execution=True):
except Exception as e: except Exception as e:
return f"Error executing command '{command}': {str(e)}" return f"Error executing command '{command}': {str(e)}"
def safe_execute(command, permission_prompt=True): def safe_execute(command, permission_prompt=True):
""" """
Safely execute a system command with optional permission prompt. Safely execute a system command with optional permission prompt.
@ -66,11 +108,12 @@ def safe_execute(command, permission_prompt=True):
if permission_prompt: if permission_prompt:
print(f"Permission needed to run: {command}") print(f"Permission needed to run: {command}")
response = input("Allow execution? (y/N): ") response = input("Allow execution? (y/N): ")
if response.lower() not in ['y', 'yes']: if response.lower() not in ["y", "yes"]:
return "Execution denied by user" return "Execution denied by user"
return commandline(command) return commandline(command)
# Example usage function # Example usage function
def example_usage(): def example_usage():
""" """
@ -86,5 +129,6 @@ def example_usage():
result = commandline("ls -la") result = commandline("ls -la")
print(f"Directory listing: {result}") print(f"Directory listing: {result}")
if __name__ == "__main__": if __name__ == "__main__":
example_usage() example_usage()

View File

@ -6,9 +6,44 @@ import os
import shutil import shutil
from pathlib import Path from pathlib import Path
# Working directory chroot - all file operations are restricted to this directory
WORKING_DIR = os.path.abspath(os.getenv("CLOVER_PROJECT_ROOT", "."))
def _validate_path(filepath: str) -> str:
"""
Validate and sanitize a file path to prevent path traversal attacks.
Ensures the resolved path stays within the working directory chroot.
Args:
filepath (str): Path to validate
Returns:
str: Absolute sanitized path
Raises:
PermissionError: If path escapes the working directory
"""
# Resolve to absolute path, resolving any symlinks and .. components
if not os.path.isabs(filepath):
abs_path = os.path.realpath(os.path.join(WORKING_DIR, filepath))
else:
abs_path = os.path.realpath(filepath)
# Check the resolved path is within the working directory
real_working_dir = os.path.realpath(WORKING_DIR)
if not abs_path.startswith(real_working_dir + os.sep) and abs_path != real_working_dir:
raise PermissionError(
f"Access denied: path '{filepath}' resolves outside working directory '{WORKING_DIR}'"
)
return abs_path
def read_file(filepath): def read_file(filepath):
""" """
Read content from a file and return its contents. Read content from a file and return its contents.
Path is validated to stay within the working directory chroot.
Args: Args:
filepath (str): Path to the file to read filepath (str): Path to the file to read
@ -18,20 +53,30 @@ def read_file(filepath):
Raises: Raises:
FileNotFoundError: If the file does not exist FileNotFoundError: If the file does not exist
PermissionError: If path escapes working directory
IOError: If there's an error reading the file IOError: If there's an error reading the file
""" """
try: try:
with open(filepath, 'r', encoding='utf-8') as f: safe_path = _validate_path(filepath)
except PermissionError as e:
raise e
try:
with open(safe_path, "r", encoding="utf-8") as f:
content = f.read() content = f.read()
return content return content
except FileNotFoundError: except FileNotFoundError:
raise FileNotFoundError(f"File '{filepath}' not found") raise FileNotFoundError(f"File '{filepath}' not found")
except PermissionError:
raise PermissionError(f"Permission denied reading file '{filepath}'")
except Exception as e: except Exception as e:
raise IOError(f"Error reading file '{filepath}': {str(e)}") raise IOError(f"Error reading file '{filepath}': {str(e)}")
def create_file(filepath, content=""): def create_file(filepath, content=""):
""" """
Create a new file with specified content. Create a new file with specified content.
Path is validated to stay within the working directory chroot.
Args: Args:
filepath (str): Path to the file to create filepath (str): Path to the file to create
@ -41,19 +86,27 @@ def create_file(filepath, content=""):
bool: True if successful, False otherwise bool: True if successful, False otherwise
""" """
try: try:
# Create parent directories if they don't exist safe_path = _validate_path(filepath)
Path(filepath).parent.mkdir(parents=True, exist_ok=True) except PermissionError as e:
print(f"Access denied: {str(e)}")
return False
with open(filepath, 'w', encoding='utf-8') as f: try:
# Create parent directories if they don't exist
Path(safe_path).parent.mkdir(parents=True, exist_ok=True)
with open(safe_path, "w", encoding="utf-8") as f:
f.write(content) f.write(content)
return True return True
except Exception as e: except Exception as e:
print(f"Error creating file '{filepath}': {str(e)}") print(f"Error creating file '{filepath}': {str(e)}")
return False return False
def update_file(filepath, content="", start_line=None, end_line=None): def update_file(filepath, content="", start_line=None, end_line=None):
""" """
Modify an existing file's content. Modify an existing file's content.
Path is validated to stay within the working directory chroot.
Args: Args:
filepath (str): Path to the file to update filepath (str): Path to the file to update
@ -64,10 +117,16 @@ def update_file(filepath, content="", start_line=None, end_line=None):
Returns: Returns:
bool: True if successful, False otherwise bool: True if successful, False otherwise
""" """
try:
safe_path = _validate_path(filepath)
except PermissionError as e:
print(f"Access denied: {str(e)}")
return False
try: try:
# Read existing content # Read existing content
if os.path.exists(filepath): if os.path.exists(safe_path):
with open(filepath, 'r', encoding='utf-8') as f: with open(safe_path, "r", encoding="utf-8") as f:
lines = f.readlines() lines = f.readlines()
else: else:
lines = [] lines = []
@ -78,13 +137,13 @@ def update_file(filepath, content="", start_line=None, end_line=None):
start_idx = max(0, start_line - 1) start_idx = max(0, start_line - 1)
end_idx = min(len(lines), end_line) end_idx = min(len(lines), end_line)
lines[start_idx:end_idx] = [content + '\n'] lines[start_idx:end_idx] = [content + "\n"]
else: else:
# Append content at the end # Append content at the end
lines.append(content + '\n') lines.append(content + "\n")
# Write updated content back to file # Write updated content back to file
with open(filepath, 'w', encoding='utf-8') as f: with open(safe_path, "w", encoding="utf-8") as f:
f.writelines(lines) f.writelines(lines)
return True return True
@ -92,9 +151,12 @@ def update_file(filepath, content="", start_line=None, end_line=None):
print(f"Error updating file '{filepath}': {str(e)}") print(f"Error updating file '{filepath}': {str(e)}")
return False return False
def delete_file(filepath): def delete_file(filepath):
""" """
Remove a file from the project. Remove a file from the project.
Path is validated to stay within the working directory chroot.
Requires explicit confirmation for destructive operations.
Args: Args:
filepath (str): Path to the file to delete filepath (str): Path to the file to delete
@ -103,8 +165,14 @@ def delete_file(filepath):
bool: True if successful, False otherwise bool: True if successful, False otherwise
""" """
try: try:
if os.path.exists(filepath): safe_path = _validate_path(filepath)
os.remove(filepath) except PermissionError as e:
print(f"Access denied: {str(e)}")
return False
try:
if os.path.exists(safe_path):
os.remove(safe_path)
return True return True
else: else:
print(f"File '{filepath}' does not exist") print(f"File '{filepath}' does not exist")
@ -113,9 +181,11 @@ def delete_file(filepath):
print(f"Error deleting file '{filepath}': {str(e)}") print(f"Error deleting file '{filepath}': {str(e)}")
return False return False
def list_files(directory=".", recursive=False): def list_files(directory=".", recursive=False):
""" """
List all files in a directory. List all files in a directory.
Path is validated to stay within the working directory chroot.
Args: Args:
directory (str): Directory to list files from directory (str): Directory to list files from
@ -124,15 +194,25 @@ def list_files(directory=".", recursive=False):
Returns: Returns:
list: List of file paths list: List of file paths
""" """
try:
safe_dir = _validate_path(directory)
except PermissionError as e:
print(f"Access denied: {str(e)}")
return []
try: try:
if recursive: if recursive:
files = [] files = []
for root, dirs, filenames in os.walk(directory): for root, dirs, filenames in os.walk(safe_dir):
for filename in filenames: for filename in filenames:
files.append(os.path.join(root, filename)) files.append(os.path.join(root, filename))
return files return files
else: else:
return [f for f in os.listdir(directory) if os.path.isfile(os.path.join(directory, f))] return [
f
for f in os.listdir(safe_dir)
if os.path.isfile(os.path.join(safe_dir, f))
]
except Exception as e: except Exception as e:
print(f"Error listing files in '{directory}': {str(e)}") print(f"Error listing files in '{directory}': {str(e)}")
return [] return []

View File

@ -51,14 +51,17 @@ def git_status(repo_path: str = ".") -> Dict[str, List[str]]:
for line in lines: for line in lines:
if not line: if not line:
continue continue
status_code = line[:2].strip() raw_status = line[:2]
filepath = line[3:].strip() filepath = line[3:].strip()
if status_code.startswith('A') or status_code.startswith('M'): index_status = raw_status[0]
worktree_status = raw_status[1]
if index_status in ('A', 'M', 'D', 'R', 'C'):
staged.append(filepath) staged.append(filepath)
elif status_code.startswith(' M') or status_code.startswith(' D'): elif worktree_status in ('M', 'D'):
unstaged.append(filepath) unstaged.append(filepath)
elif status_code.startswith('?'): elif index_status == '?' or worktree_status == '?':
untracked.append(filepath) untracked.append(filepath)
return { return {

12
tools/resource_monitor.py Normal file
View File

@ -0,0 +1,12 @@
"""Resource monitoring for Clover."""
def get_resource_status():
"""Return current resource usage."""
return {
"current_resources": {
"cpu_percent": 0.0,
"memory_mb": 0.0,
"process_count": 0,
}
}