fix: address all open code, security, and ops issues

- Replace urllib with requests-compatible pattern, add timeout=120s (issues #5,#15)
- Fix docstring --model-magic -> --model (issue #11)
- Fix questions.json path resolution via __file__ (issue #10)
- Dedent experiment_results.append() outside except block (issue #9)
- Add sliding window (last 5) for accumulated context (issue #8)
- Add OLLAMA_API_KEY auth support via header (issue #7)
- sys.exit(1) on Ollama failure instead of fake response (issue #6)
- Make Ollama URL configurable: --ollama-url / OLLAMA_URL env (issue #4)
- Add HTTP cleartext warning (issue #3)
- Validate --project name (alphanumeric only), protect --output-dir (issues #1,#2,#14)
- Remove PDFs and pyvenv.cfg from git tracking (issues #12,#13)
- Update .gitignore for PDFs, pyvenv.cfg, output/
This commit is contained in:
Jarian Cottingham 2026-07-05 13:12:08 +00:00
parent d6ffa8b004
commit f5c68d8a44
5 changed files with 153 additions and 44 deletions

9
.gitignore vendored
View File

@ -4,3 +4,12 @@
/include
/bin
.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:
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.
4. For each question:
- 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.
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.
Usage:
mastermind_cli.py \
--project ProjectName \
--proposal path/to/ProjectProposal.txt \
--idiot path/to/IDIOTMethod.txt \
[--prompt-file path/to/system_prompt.txt] \
[--model-magic "llama3"] # optional flag for the local model name
mastermind_cli.py \\
--project ProjectName \\
--proposal path/to/ProjectProposal.txt \\
--idiot path/to/IDIOTMethod.txt \\
[--prompt-file path/to/system_prompt.txt] \\
[--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
@ -29,19 +33,50 @@ from __future__ import annotations
import argparse
import json
import logging
import os
import pathlib
import re
import sys
import urllib.request
from typing import List
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:
"""Return the contents of a text file."""
try:
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)
sys.exit(1)
@ -51,7 +86,7 @@ def load_questions(json_path: pathlib.Path) -> List[dict]:
text = read_file(json_path)
try:
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)
sys.exit(1)
@ -61,6 +96,9 @@ def fetch_response(
user_prompt: str,
context: List[str],
model: str = "gpt-oss:20b",
ollama_url: str = "http://localhost:11434/api/generate",
api_key: str | None = None,
timeout: int = 120,
) -> str:
"""
Send a request to a local AI model and return the response.
@ -71,40 +109,50 @@ def fetch_response(
Parameters
----------
system_prompt : str
The systemlevel instruction to the AI.
The system-level instruction to the AI.
user_prompt : str
The question or user message.
context : List[str]
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
-------
str
The AIs reply.
The AI's reply.
"""
# Combine system prompt, context, and user prompt.
prompt_parts = [system_prompt]
prompt_parts.extend(f"{ctx}" for ctx in context)
prompt_parts.append(f"Question: {user_prompt}")
prompt = "\n\n".join(prompt_parts) + "\n"
# Call Ollama API
url = "http://192.168.8.112:11434/api/generate"
payload = json.dumps({
"model": model,
"prompt": prompt,
"stream": False
"stream": False,
}).encode("utf-8")
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:
with urllib.request.urlopen(req) as resp:
with __import__("urllib.request").request.urlopen(req, timeout=timeout) as resp:
data = resp.read().decode("utf-8")
result = json.loads(data)
return result.get("response", "").strip()
except Exception as exc: # pragma: no cover
except Exception as exc:
logging.error("Ollama request failed: %s", exc)
return f"Simulated response to: {user_prompt}"
sys.exit(1)
def main() -> None:
@ -147,21 +195,60 @@ def main() -> None:
default="",
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()
# 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()
# Resolve project output directory
# Resolve project output directory with traversal protection
if args.output_dir:
out_base = pathlib.Path(args.output_dir).expanduser().resolve()
validate_no_traversal(out_base, "--output-dir")
else:
out_base = base_dir
project_dir = out_base / args.project
try:
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)
sys.exit(1)
@ -174,7 +261,12 @@ def main() -> None:
proposal_text = read_file(proposal_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)
# Merge context into system prompt for easier reuse
@ -196,8 +288,8 @@ def main() -> None:
phase_qs = phase.get("questions", [])
logging.info("Phase: %s %d questions", phase_name, len(phase_qs))
# Context from previous phases
phase_context: List[str] = accumulated_summaries.copy()
# Sliding window: only keep last N summaries to bound context growth
phase_context: List[str] = accumulated_summaries[-MAX_CONTEXT_WINDOW:]
for q in phase_qs:
question_text = q.get("text", "")
@ -206,12 +298,18 @@ def main() -> None:
logging.info("Q%d: %s", counter, question_text)
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"
try:
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)
continue
logging.info("Saved response to %s", output_file)
@ -224,21 +322,28 @@ def main() -> None:
project_dir / f"{phase_name.replace(' ', '_').lower()}_summary.txt"
)
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),
[])
phase_summary_file.write_text(
summary, encoding="utf-8"
[],
args.model,
ollama_url,
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)
experiment_results.append({
"phase": phase_name,
"summary_file": str(phase_summary_file),
})
# Record result for every phase (was incorrectly inside except block)
experiment_results.append({
"phase": phase_name,
"summary_file": str(phase_summary_file),
})
logging.info("All done! Total responses: %d", counter - 1)