Merge pull request 'Fix all open issues - code, security, ops' (#17) from fix/core-issues into main

Reviewed-on: https://git.example.com/jarianc/MasterMind/pulls/17
This commit is contained in:
Jarian Cottingham 2026-07-05 08:17:16 -05:00
commit 0f83032cd6
5 changed files with 153 additions and 44 deletions

9
.gitignore vendored
View File

@ -4,3 +4,12 @@
/include /include
/bin /bin
.ropeproject .ropeproject
# Virtual environment config (contains personal paths)
pyvenv.cfg
# Large binaries (PDFs should not be tracked in git)
*.pdf
# Output directories
output/

Binary file not shown.

View File

@ -1,5 +0,0 @@
home = /opt/homebrew/opt/python@3.13/bin
include-system-site-packages = false
version = 3.13.5
executable = /opt/homebrew/Cellar/python@3.13/3.13.5/Frameworks/Python.framework/Versions/3.13/bin/python3.13
command = /opt/homebrew/opt/python@3.13/bin/python3.13 -m venv /Users/user/Projects/Master Mind

View File

@ -5,23 +5,27 @@ MasterMind CLI
This script will: This script will:
1. Load the list of questions from `Master Mind/questions.json`. 1. Load the list of questions from `Master Mind/questions.json`.
2. Load the system prompt from a userprovided file (default: `system_prompt.txt`). 2. Load the system prompt from a user-provided file (default: `system_prompt.txt`).
3. Load the proposal and IDIOT method documents. 3. Load the proposal and IDIOT method documents.
4. For each question: 4. For each question:
- Send a request to a local AI model. - Send a request to a local AI model.
- Store the response in a file inside a projectnamed folder. - Store the response in a file inside a project-named folder.
- Log the activity to the console. - Log the activity to the console.
A very small helper is used to talk to a local model; it can be replaced with a real A very small helper is used to talk to a local model; it can be replaced with a real
LLM client (Llama.cpp, Ollama, FastLLM, etc.) by editing the ``fetch_response`` function. LLM client (Llama.cpp, Ollama, FastLLM, etc.) by editing the ``fetch_response`` function.
Usage: Usage:
mastermind_cli.py \ mastermind_cli.py \\
--project ProjectName \ --project ProjectName \\
--proposal path/to/ProjectProposal.txt \ --proposal path/to/ProjectProposal.txt \\
--idiot path/to/IDIOTMethod.txt \ --idiot path/to/IDIOTMethod.txt \\
[--prompt-file path/to/system_prompt.txt] \ [--prompt-file path/to/system_prompt.txt] \\
[--model-magic "llama3"] # optional flag for the local model name [--model "llama3"] # optional flag for the local model name
[--ollama-url "http://..."] # optional Ollama endpoint (default: OLLAMA_URL env)
[--questions-file path.json] # optional questions file (default: questions.json)
[--output-dir path] # optional output directory
[--api-key "secret"] # optional API key for Ollama auth
""" """
from __future__ import annotations from __future__ import annotations
@ -29,19 +33,50 @@ from __future__ import annotations
import argparse import argparse
import json import json
import logging import logging
import os
import pathlib import pathlib
import re
import sys import sys
import urllib.request
from typing import List from typing import List
logging.basicConfig(level=logging.INFO, format="%(message)s") logging.basicConfig(level=logging.INFO, format="%(message)s")
# Sliding window size for accumulated context (issue #8)
MAX_CONTEXT_WINDOW = 5
def validate_project_name(name: str) -> str:
"""Validate project name contains only safe characters. Prevents path traversal."""
if not re.match(r"^[a-zA-Z0-9_-]+$", name):
logging.error(
"Invalid project name '%s'. Only alphanumeric, hyphens, and underscores allowed.",
name,
)
sys.exit(1)
return name
def validate_no_traversal(path: pathlib.Path, label: str) -> pathlib.Path:
"""Ensure resolved path does not escape its intended base directory."""
resolved = path.resolve()
base = pathlib.Path(".").resolve()
try:
resolved.relative_to(base)
except ValueError:
logging.error(
"Invalid %s '%s': path escapes the allowed base directory.",
label,
path,
)
sys.exit(1)
return resolved
def read_file(path: pathlib.Path) -> str: def read_file(path: pathlib.Path) -> str:
"""Return the contents of a text file.""" """Return the contents of a text file."""
try: try:
return path.read_text(encoding="utf-8") return path.read_text(encoding="utf-8")
except Exception as exc: # pragma: no cover except Exception as exc:
logging.error("Could not read %s: %s", path, exc) logging.error("Could not read %s: %s", path, exc)
sys.exit(1) sys.exit(1)
@ -51,7 +86,7 @@ def load_questions(json_path: pathlib.Path) -> List[dict]:
text = read_file(json_path) text = read_file(json_path)
try: try:
return json.loads(text) return json.loads(text)
except json.JSONDecodeError as exc: # pragma: no cover except json.JSONDecodeError as exc:
logging.error("Invalid JSON in %s: %s", json_path, exc) logging.error("Invalid JSON in %s: %s", json_path, exc)
sys.exit(1) sys.exit(1)
@ -61,6 +96,9 @@ def fetch_response(
user_prompt: str, user_prompt: str,
context: List[str], context: List[str],
model: str = "gpt-oss:20b", model: str = "gpt-oss:20b",
ollama_url: str = "http://localhost:11434/api/generate",
api_key: str | None = None,
timeout: int = 120,
) -> str: ) -> str:
""" """
Send a request to a local AI model and return the response. Send a request to a local AI model and return the response.
@ -71,40 +109,50 @@ def fetch_response(
Parameters Parameters
---------- ----------
system_prompt : str system_prompt : str
The systemlevel instruction to the AI. The system-level instruction to the AI.
user_prompt : str user_prompt : str
The question or user message. The question or user message.
context : List[str] context : List[str]
Optional additional context to prepend to the request. Optional additional context to prepend to the request.
model : str
The model name to use.
ollama_url : str
The Ollama API endpoint URL.
api_key : str | None
Optional API key for authentication.
timeout : int
Request timeout in seconds.
Returns Returns
------- -------
str str
The AIs reply. The AI's reply.
""" """
# Combine system prompt, context, and user prompt.
prompt_parts = [system_prompt] prompt_parts = [system_prompt]
prompt_parts.extend(f"{ctx}" for ctx in context) prompt_parts.extend(f"{ctx}" for ctx in context)
prompt_parts.append(f"Question: {user_prompt}") prompt_parts.append(f"Question: {user_prompt}")
prompt = "\n\n".join(prompt_parts) + "\n" prompt = "\n\n".join(prompt_parts) + "\n"
# Call Ollama API
url = "http://192.168.8.112:11434/api/generate"
payload = json.dumps({ payload = json.dumps({
"model": model, "model": model,
"prompt": prompt, "prompt": prompt,
"stream": False "stream": False,
}).encode("utf-8") }).encode("utf-8")
headers = {"Content-Type": "application/json"} headers = {"Content-Type": "application/json"}
req = urllib.request.Request(url, data=payload, headers=headers, method="POST") if api_key:
headers["Authorization"] = f"Bearer {api_key}"
req = __import__("urllib.request").request.Request(
ollama_url, data=payload, headers=headers, method="POST"
)
try: try:
with urllib.request.urlopen(req) as resp: with __import__("urllib.request").request.urlopen(req, timeout=timeout) as resp:
data = resp.read().decode("utf-8") data = resp.read().decode("utf-8")
result = json.loads(data) result = json.loads(data)
return result.get("response", "").strip() return result.get("response", "").strip()
except Exception as exc: # pragma: no cover except Exception as exc:
logging.error("Ollama request failed: %s", exc) logging.error("Ollama request failed: %s", exc)
return f"Simulated response to: {user_prompt}" sys.exit(1)
def main() -> None: def main() -> None:
@ -147,21 +195,60 @@ def main() -> None:
default="", default="",
help="Custom output directory for the project folder (default: current dir).", help="Custom output directory for the project folder (default: current dir).",
) )
parser.add_argument(
"--ollama-url",
default=None,
help="Ollama API endpoint URL (default: $OLLAMA_URL or http://localhost:11434/api/generate).",
)
parser.add_argument(
"--api-key",
default=None,
help="API key for Ollama authentication (default: $OLLAMA_API_KEY).",
)
parser.add_argument(
"--questions-file",
default=None,
help="Path to questions JSON file (default: questions.json next to script).",
)
parser.add_argument(
"--timeout",
type=int,
default=120,
help="Request timeout in seconds for Ollama calls (default: 120).",
)
args = parser.parse_args() args = parser.parse_args()
# Resolve Ollama URL: CLI arg > env var > default
ollama_url = args.ollama_url or os.environ.get(
"OLLAMA_URL", "http://localhost:11434/api/generate"
)
api_key = args.api_key or os.environ.get("OLLAMA_API_KEY")
# Warn if using HTTP (cleartext)
if ollama_url.startswith("http://"):
logging.warning(
"Using unencrypted HTTP for Ollama endpoint. "
"Sensitive project data will be transmitted in cleartext. "
"Use https:// or ensure network isolation."
)
# Validate project name against path traversal
validate_project_name(args.project)
base_dir = pathlib.Path.cwd() base_dir = pathlib.Path.cwd()
# Resolve project output directory # Resolve project output directory with traversal protection
if args.output_dir: if args.output_dir:
out_base = pathlib.Path(args.output_dir).expanduser().resolve() out_base = pathlib.Path(args.output_dir).expanduser().resolve()
validate_no_traversal(out_base, "--output-dir")
else: else:
out_base = base_dir out_base = base_dir
project_dir = out_base / args.project project_dir = out_base / args.project
try: try:
project_dir.mkdir(parents=True, exist_ok=True) project_dir.mkdir(parents=True, exist_ok=True)
except Exception as exc: # pragma: no cover except Exception as exc:
logging.error("Could not create project directory %s: %s", project_dir, exc) logging.error("Could not create project directory %s: %s", project_dir, exc)
sys.exit(1) sys.exit(1)
@ -174,7 +261,12 @@ def main() -> None:
proposal_text = read_file(proposal_path) proposal_text = read_file(proposal_path)
idiot_text = read_file(idiot_path) idiot_text = read_file(idiot_path)
questions_path = base_dir.parent / "questions.json" # Resolve questions file: CLI arg > script-relative > project root
if args.questions_file:
questions_path = pathlib.Path(args.questions_file)
else:
script_dir = pathlib.Path(__file__).resolve().parent
questions_path = script_dir.parent / "questions.json"
questions = load_questions(questions_path) questions = load_questions(questions_path)
# Merge context into system prompt for easier reuse # Merge context into system prompt for easier reuse
@ -196,8 +288,8 @@ def main() -> None:
phase_qs = phase.get("questions", []) phase_qs = phase.get("questions", [])
logging.info("Phase: %s %d questions", phase_name, len(phase_qs)) logging.info("Phase: %s %d questions", phase_name, len(phase_qs))
# Context from previous phases # Sliding window: only keep last N summaries to bound context growth
phase_context: List[str] = accumulated_summaries.copy() phase_context: List[str] = accumulated_summaries[-MAX_CONTEXT_WINDOW:]
for q in phase_qs: for q in phase_qs:
question_text = q.get("text", "") question_text = q.get("text", "")
@ -206,12 +298,18 @@ def main() -> None:
logging.info("Q%d: %s", counter, question_text) logging.info("Q%d: %s", counter, question_text)
response = fetch_response( response = fetch_response(
full_system_prompt, question_text, phase_context, args.model full_system_prompt,
question_text,
phase_context,
args.model,
ollama_url,
api_key,
args.timeout,
) )
output_file = project_dir / f"question_{counter:03d}.txt" output_file = project_dir / f"question_{counter:03d}.txt"
try: try:
output_file.write_text(response, encoding="utf-8") output_file.write_text(response, encoding="utf-8")
except Exception as exc: # pragma: no cover except Exception as exc:
logging.error("Could not write %s: %s", output_file, exc) logging.error("Could not write %s: %s", output_file, exc)
continue continue
logging.info("Saved response to %s", output_file) logging.info("Saved response to %s", output_file)
@ -224,21 +322,28 @@ def main() -> None:
project_dir / f"{phase_name.replace(' ', '_').lower()}_summary.txt" project_dir / f"{phase_name.replace(' ', '_').lower()}_summary.txt"
) )
try: try:
summary = fetch_response("Make a clear and concise summary that's no more than 500 tokens on the following input", summary = fetch_response(
"Make a clear and concise summary that's no more than 500 tokens on the following input",
"\n\n".join(accumulated_summaries), "\n\n".join(accumulated_summaries),
[]) [],
args.model,
phase_summary_file.write_text( ollama_url,
summary, encoding="utf-8" api_key,
args.timeout,
) )
logging.info("Saved perphase summary to %s", phase_summary_file)
except Exception as exc: # pragma: no cover phase_summary_file.write_text(summary, encoding="utf-8")
logging.info("Saved per-phase summary to %s", phase_summary_file)
except SystemExit:
raise
except Exception as exc:
logging.error("Could not write %s: %s", phase_summary_file, exc) logging.error("Could not write %s: %s", phase_summary_file, exc)
experiment_results.append({ # Record result for every phase (was incorrectly inside except block)
"phase": phase_name, experiment_results.append({
"summary_file": str(phase_summary_file), "phase": phase_name,
}) "summary_file": str(phase_summary_file),
})
logging.info("All done! Total responses: %d", counter - 1) logging.info("All done! Total responses: %d", counter - 1)