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:
parent
d6ffa8b004
commit
f5c68d8a44
9
.gitignore
vendored
9
.gitignore
vendored
@ -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.
Binary file not shown.
@ -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
|
||||
@ -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 user‑provided 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 project‑named 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 system‑level 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 AI’s 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,17 +322,24 @@ 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 per‑phase 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)
|
||||
|
||||
# Record result for every phase (was incorrectly inside except block)
|
||||
experiment_results.append({
|
||||
"phase": phase_name,
|
||||
"summary_file": str(phase_summary_file),
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user