diff --git a/.gitignore b/.gitignore
index 4a5142aa..05279956 100644
--- a/.gitignore
+++ b/.gitignore
@@ -19,6 +19,11 @@ data/*
!data/spreadsheetbench_id_split/**
!data/alfworld_path_split/
!data/alfworld_path_split/**
+# ─── Internal analysis docs (private, not for open-source release) ───────────
+doc-internals/
+
+# ─── Build / local output dirs ───────────────────────────────────────────────
+out/
outputs/
logs/
external/
diff --git a/docs/sleep/README.md b/docs/sleep/README.md
index 9ad55be3..2b740ac9 100644
--- a/docs/sleep/README.md
+++ b/docs/sleep/README.md
@@ -17,7 +17,7 @@ normal agent requests.
One "night":
```
-harvest Claude Code / Codex / Cursor transcripts → mine recurring tasks → replay in isolated model calls
+harvest Claude Code / Codex / Cursor transcripts → mine recurring tasks → replay via the configured backend (isolation varies by backend; mock/handoff make no network calls)
→ consolidate (reflect → bounded edit → GATE on real held-out tasks)
→ stage proposal → (you) adopt
```
diff --git a/scripts/train.py b/scripts/train.py
index 63cad9e1..dbce3585 100644
--- a/scripts/train.py
+++ b/scripts/train.py
@@ -383,8 +383,24 @@ def parse_args() -> argparse.Namespace:
def load_config(args: argparse.Namespace) -> dict:
"""Load config with _base_ inheritance, then apply CLI overrides."""
+ import warnings
from skillopt.config import load_config as _load, flatten_config, is_structured
+ # F08: Warn when API keys are supplied on the CLI — prefer env vars or managed identity.
+ for _cli_key in (
+ "azure_api_key", "azure_openai_api_key",
+ "optimizer_azure_openai_api_key", "target_azure_openai_api_key",
+ "minimax_api_key",
+ ):
+ if getattr(args, _cli_key, None):
+ warnings.warn(
+ f"--{_cli_key} is deprecated: provide credentials via the "
+ f"AZURE_OPENAI_API_KEY environment variable or set "
+ f"--azure_openai_auth_mode=managed_identity instead.",
+ DeprecationWarning,
+ stacklevel=2,
+ )
+
cfg = _load(args.config, overrides=args.cfg_options)
structured = is_structured(cfg)
diff --git a/skillopt/envs/spreadsheetbench/codegen_agent.py b/skillopt/envs/spreadsheetbench/codegen_agent.py
index a4948f1f..113b311a 100644
--- a/skillopt/envs/spreadsheetbench/codegen_agent.py
+++ b/skillopt/envs/spreadsheetbench/codegen_agent.py
@@ -260,17 +260,34 @@ def _build_codex_driver() -> str:
return (
"import pathlib\n"
"import re\n"
- "import sys\n"
- "import traceback\n\n"
+ "import subprocess\n"
+ "import sys\n\n"
'INPUT_PATH = "input.xlsx"\n'
'OUTPUT_PATH = "output.xlsx"\n'
"code = pathlib.Path('solution.py').read_text(encoding='utf-8')\n"
"code = re.sub(r'^\\s*(INPUT_PATH|OUTPUT_PATH)\\s*=\\s*.+$', '', code, flags=re.MULTILINE)\n"
- "globals_dict = {'__name__': '__main__', 'INPUT_PATH': INPUT_PATH, 'OUTPUT_PATH': OUTPUT_PATH}\n"
- "try:\n"
- " exec(compile(code, 'solution.py', 'exec'), globals_dict, globals_dict)\n"
- "except Exception:\n"
- " traceback.print_exc()\n"
+ "# Write patched code to a temporary file and run it in a clean subprocess\n"
+ "# (avoids exec/compile of untrusted LLM-generated code in the current process).\n"
+ "_patched = pathlib.Path('_driver_runner.py')\n"
+ "_patched.write_text(\n"
+ " f'INPUT_PATH = {INPUT_PATH!r}\\nOUTPUT_PATH = {OUTPUT_PATH!r}\\n' + code,\n"
+ " encoding='utf-8',\n"
+ ")\n"
+ "import os as _os\n"
+ "_safe_env = {\n"
+ " 'PATH': _os.environ.get('PATH', '/usr/bin:/bin'),\n"
+ " 'HOME': str(pathlib.Path('_driver_runner.py').parent.resolve()),\n"
+ "}\n"
+ "if _os.name == 'nt':\n"
+ " _safe_env['SYSTEMROOT'] = _os.environ.get('SYSTEMROOT', '')\n"
+ " _safe_env['TEMP'] = _safe_env['HOME']\n"
+ " _safe_env['TMP'] = _safe_env['HOME']\n"
+ "_safe_env = {k: v for k, v in _safe_env.items() if v}\n"
+ "_res = subprocess.run([sys.executable, str(_patched)], capture_output=True, text=True, env=_safe_env)\n"
+ "if _res.returncode != 0:\n"
+ " import traceback as _tb\n"
+ " print(_res.stdout, end='')\n"
+ " print(_res.stderr, end='')\n"
" sys.exit(2)\n"
)
diff --git a/skillopt/envs/spreadsheetbench/executor.py b/skillopt/envs/spreadsheetbench/executor.py
index 24421f95..6033c9c4 100644
--- a/skillopt/envs/spreadsheetbench/executor.py
+++ b/skillopt/envs/spreadsheetbench/executor.py
@@ -46,12 +46,27 @@ def run_generated_code(code: str, input_path: str, output_path: str, timeout: in
with tempfile.NamedTemporaryFile("w", suffix=".py", delete=False) as f:
f.write(script)
tmp = f.name
+ # Build a minimal environment so the generated code cannot read API keys,
+ # cloud credentials, or other secrets from the current process environment.
+ import platform as _platform
+ _safe_env: dict[str, str] = {
+ "PATH": os.environ.get("PATH", "/usr/bin:/bin"),
+ "HOME": os.path.dirname(output_path),
+ "TMPDIR": tempfile.gettempdir(),
+ }
+ if _platform.system() == "Windows":
+ _safe_env["SYSTEMROOT"] = os.environ.get("SYSTEMROOT", "")
+ _safe_env["TEMP"] = tempfile.gettempdir()
+ _safe_env["TMP"] = tempfile.gettempdir()
+ # Drop empty entries (env dict values must be non-empty strings)
+ _safe_env = {k: v for k, v in _safe_env.items() if v}
try:
proc = subprocess.run(
[sys.executable, tmp],
capture_output=True,
text=True,
timeout=timeout if timeout and timeout > 0 else None,
+ env=_safe_env,
)
if proc.returncode != 0:
return False, (proc.stdout + "\n" + proc.stderr).strip()
diff --git a/skillopt/envs/spreadsheetbench/react_agent.py b/skillopt/envs/spreadsheetbench/react_agent.py
index 2e729534..f9646ac5 100644
--- a/skillopt/envs/spreadsheetbench/react_agent.py
+++ b/skillopt/envs/spreadsheetbench/react_agent.py
@@ -9,6 +9,7 @@
import json
import os
+import shlex
import subprocess
from skillopt.model import chat_target_messages
@@ -248,13 +249,28 @@ def _auto_verify(work_dir: str) -> str:
return f"\n\n[AUTO-VERIFY] Could not inspect output: {e}"
+# Commands that the ReAct agent is allowed to run (benchmark only needs Python).
+_CMD_ALLOW = {"python", "python3"}
+
+
# ── Bash execution ────────────────────────────────────────────────────────────
def _run_bash(cmd: str, work_dir: str, timeout: int = 60) -> str:
try:
+ parts = shlex.split(cmd)
+ if not parts:
+ return "[error: empty command]"
+ exe_name = os.path.basename(parts[0]).lower()
+ # Strip .exe suffix on Windows (python.exe → python)
+ exe_stem = exe_name.split(".")[0]
+ if exe_stem not in _CMD_ALLOW:
+ return (
+ f"[blocked: '{parts[0]}' not in allow-list {sorted(_CMD_ALLOW)}; "
+ "use Python to manipulate spreadsheets]"
+ )
proc = subprocess.run(
- cmd,
- shell=True,
+ parts,
+ shell=False,
capture_output=True,
text=True,
timeout=timeout,
diff --git a/skillopt_sleep/backend.py b/skillopt_sleep/backend.py
index f2cc3c84..d0015a29 100644
--- a/skillopt_sleep/backend.py
+++ b/skillopt_sleep/backend.py
@@ -1082,7 +1082,7 @@ def _call(self, prompt: str, *, max_tokens: int = 1024) -> str:
"--stream", "off",
"--no-color",
"--log-level", "none",
- "--allow-all-tools",
+ "--allowed-tools", os.environ.get("COPILOT_ALLOWED_TOOLS", "Bash"),
"-C", clean_cwd,
]
if not self.full_env:
@@ -1197,7 +1197,7 @@ def attempt_with_tools(self, task, skill, memory, tools):
"--stream", "off",
"--no-color",
"--log-level", "none",
- "--allow-all-tools",
+ "--allowed-tools", os.environ.get("COPILOT_ALLOWED_TOOLS", "Bash"),
"-C", work,
]
if not self.full_env:
diff --git a/skillopt_sleep/cycle.py b/skillopt_sleep/cycle.py
index f177d4c4..e91a879c 100644
--- a/skillopt_sleep/cycle.py
+++ b/skillopt_sleep/cycle.py
@@ -10,6 +10,7 @@
from __future__ import annotations
import os
+import re
import shutil
import sys
from dataclasses import dataclass
@@ -30,6 +31,56 @@
from skillopt_sleep.types import SessionDigest, SleepReport, TaskRecord
+# ── Auto-adopt safety gate ────────────────────────────────────────────────────────────
+# Block auto-adoption if any proposed edit content contains high-risk patterns.
+_DANGEROUS_CONTENT = re.compile(
+ r"(?i)(api[_\-]?key|credential|exfil|leak|bypass|ignore\s*instruction"
+ r"|x-bypass|token\s*=\s*[A-Za-z0-9])"
+)
+
+
+def _safe_edits(edits: list) -> bool:
+ """Return False (and log) if any edit contains a dangerous-looking pattern."""
+ import logging as _logging
+ _log = _logging.getLogger(__name__)
+ for e in edits:
+ content = getattr(e, "content", "") or ""
+ if _DANGEROUS_CONTENT.search(content):
+ _log.warning(
+ "auto_adopt BLOCKED: dangerous pattern detected in edit content: %s",
+ content[:120],
+ )
+ return False
+ return True
+
+
+def _make_model_key(cfg: SleepConfig) -> str:
+ """Stable string identifying the backend+model combination for this cycle."""
+ return "{}::{}".format(
+ cfg.get("backend", "mock"),
+ cfg.get("model", ""),
+ )
+
+
+def _check_model_change(cfg: SleepConfig, state: SleepState) -> None:
+ """Warn when the backend/model has changed since the last night.
+
+ Skill text is backend-specific; adopting edits from a different model's
+ reflections into a new model's skill file can cause regressions.
+ This is advisory only — the cycle continues either way.
+ """
+ current_key = _make_model_key(cfg)
+ prior_key = state.last_model_key
+ if prior_key and prior_key != current_key:
+ print(
+ f"[sleep] WARNING: model changed since last night "
+ f"(was {prior_key!r}, now {current_key!r}). "
+ "Learned skill text may not transfer cleanly. "
+ "Consider starting from a fresh skill document.",
+ file=sys.stderr,
+ )
+
+
@dataclass
class CycleOutcome:
report: SleepReport
@@ -127,6 +178,7 @@ def run_sleep_cycle(
"""
cfg = cfg or load_config()
state = SleepState.load(cfg.state_path)
+ _check_model_change(cfg, state) # F16: warn if model changed between nights
night = state.begin_night(clock)
project = _project_paths(cfg)
started = _now_iso(clock)
@@ -409,10 +461,12 @@ def run_sleep_cycle(
"baseline": result.baseline_score, "candidate": result.candidate_score,
"n_tasks": len(tasks), "staging": staging_dir,
})
+ state.set_last_model_key(_make_model_key(cfg)) # F16: track model for next night
# ── 6. adopt (opt-in) ────────────────────────────────────────────
if cfg.get("auto_adopt") and result.accepted:
- adopted_paths = adopt_staging(staging_dir)
- adopted = bool(adopted_paths)
+ if _safe_edits(result.applied_edits):
+ adopted_paths = adopt_staging(staging_dir)
+ adopted = bool(adopted_paths)
state.save()
if ev is not None:
diff --git a/skillopt_sleep/evidence.py b/skillopt_sleep/evidence.py
index fa53891d..b3b8a44e 100644
--- a/skillopt_sleep/evidence.py
+++ b/skillopt_sleep/evidence.py
@@ -27,6 +27,8 @@
"""
from __future__ import annotations
+import hashlib
+import hmac as _hmac_mod
import json
import os
import threading
@@ -40,6 +42,11 @@ def _now_iso() -> str:
return time.strftime("%Y-%m-%dT%H:%M:%S", time.localtime())
+def _os_user() -> str:
+ """Return the current OS username for audit purposes (best-effort)."""
+ return os.environ.get("USERNAME") or os.environ.get("USER") or "unknown"
+
+
class EvidenceLog:
"""Append-only, thread-safe JSONL logger for one sleep night."""
@@ -71,13 +78,24 @@ def _clean(self, value: Any) -> Any:
# ── the one write path ────────────────────────────────────────────────
def log(self, stage: str, event: str, **data: Any) -> None:
- record = {"ts": _now_iso(), "stage": stage, "event": event}
+ record = {"ts": _now_iso(), "stage": stage, "event": event,
+ "os_user": _os_user()}
record.update(self._clean(data))
with self._lock:
self._seq += 1
record["seq"] = self._seq
try:
line = json.dumps(record, ensure_ascii=False, default=str)
+ # Optional per-record HMAC for tamper detection (F15).
+ # Set SKILLOPT_EVIDENCE_HMAC_KEY to enable.
+ _hmac_key = os.environ.get("SKILLOPT_EVIDENCE_HMAC_KEY", "")
+ if _hmac_key:
+ mac = _hmac_mod.new(
+ _hmac_key.encode(), line.encode(), hashlib.sha256
+ ).hexdigest()
+ # Append the MAC as a separate audit line so the JSONL
+ # structure of the payload line is unchanged.
+ line = line + "\n" + json.dumps({"_audit_mac": mac, "seq": self._seq})
with open(self.path, "a", encoding="utf-8") as f:
f.write(line + "\n")
except Exception:
diff --git a/skillopt_sleep/gate.py b/skillopt_sleep/gate.py
index 7eca3b43..7e17e385 100644
--- a/skillopt_sleep/gate.py
+++ b/skillopt_sleep/gate.py
@@ -8,6 +8,7 @@
"""
from __future__ import annotations
+import re
from dataclasses import dataclass
@@ -21,6 +22,9 @@ class GateResult:
best_step: int
+# \u2500\u2500 Content safety (F14) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n# Reject candidate skills that contain high-risk content before they can ever\n# be accepted by the gate, regardless of score.\n_DANGEROUS = re.compile(\n r\"(?i)(api[_\\-]?key|credential|exfil|leak|bypass\\s*security\"\n r\"|ignore\\s*instruction|x-bypass|token\\s*=\\s*[A-Za-z0-9])\"\n)\n\n\ndef _content_safe(candidate_skill: str) -> bool:\n \"\"\"Return False if the candidate skill text contains dangerous patterns.\"\"\"\n return not bool(_DANGEROUS.search(candidate_skill))
+
+
def select_gate_score(hard: float, soft: float, metric: str = "hard",
mixed_weight: float = 0.5) -> float:
"""Project (hard, soft) onto a single comparison metric."""
@@ -39,6 +43,10 @@ def evaluate_gate(candidate_skill: str, cand_hard: float, current_skill: str,
best_step: int, global_step: int, *, cand_soft: float = 0.0,
metric: str = "hard", mixed_weight: float = 0.5) -> GateResult:
"""Pure gate decision: compare candidate score to current/best."""
+ # Content safety: reject before score comparison if skill text is unsafe.
+ if not _content_safe(candidate_skill):
+ return GateResult("reject", current_skill, current_score,
+ best_skill, best_score, best_step)
cand_score = select_gate_score(cand_hard, cand_soft, metric, mixed_weight)
if cand_score > current_score:
if cand_score > best_score:
diff --git a/skillopt_sleep/harvest.py b/skillopt_sleep/harvest.py
index deb8d884..07f6477e 100644
--- a/skillopt_sleep/harvest.py
+++ b/skillopt_sleep/harvest.py
@@ -15,6 +15,8 @@
"""
from __future__ import annotations
+import hashlib
+import hmac as _hmac_mod
import json
import os
from datetime import datetime
@@ -23,6 +25,9 @@
from skillopt_sleep.types import SessionDigest
+# \u2500\u2500 Optional HMAC session integrity (F09) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n# When SKILLOPT_SESSION_HMAC_KEY is set, each session file is signed on first\n# harvest (a .sig companion is written) and verified on subsequent harvests.\n# Tampered or injected session files are skipped. Opt-in: if the env var is\n# unset (the default), signing and verification are both no-ops.\n\ndef _hmac_key() -> bytes:\n \"\"\"Return the configured HMAC key, or b'' if none configured.\"\"\"\n raw = os.environ.get(\"SKILLOPT_SESSION_HMAC_KEY\", \"\")\n return raw.encode() if raw else b\"\"\n\n\ndef _sign_session(path: str) -> None:\n \"\"\"Write a .sig file for *path* if a key is configured and no sig exists.\"\"\"\n key = _hmac_key()\n if not key:\n return\n sig_path = path + \".sig\"\n if os.path.exists(sig_path):\n return\n try:\n with open(path, \"rb\") as f:\n content = f.read()\n digest = _hmac_mod.new(key, content, hashlib.sha256).hexdigest()\n with open(sig_path, \"w\", encoding=\"utf-8\") as f:\n f.write(digest + \"\\n\")\n except OSError:\n pass # read-only fs or permission issue — skip silently\n\n\ndef _verify_session(path: str) -> bool:\n \"\"\"Return True if HMAC verification passes (or no key is configured).\"\"\"\n key = _hmac_key()\n if not key:\n return True\n sig_path = path + \".sig\"\n if not os.path.exists(sig_path):\n # No sig yet — sign on this first harvest and accept it.\n _sign_session(path)\n return True\n try:\n with open(sig_path, encoding=\"utf-8\") as f:\n expected = f.read().strip()\n with open(path, \"rb\") as f:\n content = f.read()\n actual = _hmac_mod.new(key, content, hashlib.sha256).hexdigest()\n return _hmac_mod.compare_digest(expected, actual)\n except OSError:\n return False # unreadable sig or session file \u2014 skip
+
+
# Heuristic phrases that signal the user (dis)approving of prior output.
# English-only by default. Users whose sessions are in another language can add
# their own phrases via the SKILLOPT_SLEEP_NEG_FEEDBACK / _POS_FEEDBACK env vars
@@ -322,6 +327,11 @@ def harvest(
paths.sort(key=lambda p: os.path.getmtime(p), reverse=True)
for p in paths:
+ if not _verify_session(p):
+ import logging as _logging
+ _logging.getLogger(__name__).warning(
+ "harvest: HMAC verification failed for %s — skipping", p)
+ continue
d = digest_transcript(p)
if d is None:
continue
diff --git a/skillopt_sleep/harvest_sources.py b/skillopt_sleep/harvest_sources.py
index 0b949601..2f1aa679 100644
--- a/skillopt_sleep/harvest_sources.py
+++ b/skillopt_sleep/harvest_sources.py
@@ -1,6 +1,8 @@
"""Source selection for SkillOpt-Sleep transcript harvesting."""
from __future__ import annotations
+import logging
+import os
from typing import Optional
from skillopt_sleep.harvest import harvest
@@ -8,6 +10,11 @@
from skillopt_sleep.harvest_cursor import harvest_cursor
from skillopt_sleep.types import SessionDigest
+_log = logging.getLogger(__name__)
+
+
+# \u2500\u2500 Egress policy (F10) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n# Digests whose project path falls under any egress_deny_dirs entry are not\n# sent to external LLM backends for mining (they still contribute to the\n# heuristic miner locally). Configure via config[\"egress_deny_dirs\"] = [...].\n\ndef _egress_allowed(digest: SessionDigest, cfg) -> bool:\n \"\"\"Return False if this digest's project is under a deny-dir.\"\"\"\n deny_dirs = cfg.get(\"egress_deny_dirs\") or []\n if not deny_dirs:\n return True\n project = os.path.abspath(digest.project or \"\")\n for deny in deny_dirs:\n deny_abs = os.path.abspath(deny)\n if project == deny_abs or project.startswith(deny_abs + os.sep):\n _log.info(\n \"egress blocked for session from %s (matches egress_deny_dirs)\", project\n )\n return False\n return True\n\n\ndef _filter_egress(digests: list[SessionDigest], cfg) -> list[SessionDigest]:\n \"\"\"Remove digests that are not allowed to be sent to external backends.\"\"\"\n return [d for d in digests if _egress_allowed(d, cfg)]
+
def harvest_for_config(cfg, *, since_iso: Optional[str] = None, limit: int = 0) -> list[SessionDigest]:
source = cfg.get("transcript_source", "claude")
@@ -15,21 +22,23 @@ def harvest_for_config(cfg, *, since_iso: Optional[str] = None, limit: int = 0)
invoked_project = cfg.get("invoked_project", "")
if source == "codex":
- return harvest_codex(
+ raw = harvest_codex(
cfg.codex_archived_sessions_dir,
scope=scope,
invoked_project=invoked_project,
since_iso=since_iso,
limit=limit,
)
+ return _filter_egress(raw, cfg)
if source == "cursor":
- return harvest_cursor(
+ raw = harvest_cursor(
cfg.cursor_projects_dir,
scope=scope,
invoked_project=invoked_project,
since_iso=since_iso,
limit=limit,
)
+ return _filter_egress(raw, cfg)
if source == "auto":
codex_digests = harvest_codex(
cfg.codex_archived_sessions_dir,
@@ -39,12 +48,13 @@ def harvest_for_config(cfg, *, since_iso: Optional[str] = None, limit: int = 0)
limit=limit,
)
if codex_digests:
- return codex_digests
+ return _filter_egress(codex_digests, cfg)
- return harvest(
+ raw = harvest(
cfg.transcripts_dir,
scope=scope,
invoked_project=invoked_project,
since_iso=since_iso,
limit=limit,
)
+ return _filter_egress(raw, cfg)
diff --git a/skillopt_sleep/llm_miner.py b/skillopt_sleep/llm_miner.py
index ddf07803..84582ca8 100644
--- a/skillopt_sleep/llm_miner.py
+++ b/skillopt_sleep/llm_miner.py
@@ -27,10 +27,27 @@
from skillopt_sleep.types import SessionDigest, TaskRecord
+# ── Prompt injection sanitizer ────────────────────────────────────────────────
+# Neutralise directive-style phrases that could manipulate the mining LLM.
+_INJ_RE = re.compile(
+ r"(?i)(ignore|forget|disregard|override)\s.{0,60}(instruction|rule|above|previous)",
+ re.DOTALL,
+)
+
+
+def _sanitise_prompts(raw_prompts: List[str], max_each: int = 240, max_count: int = 6) -> str:
+ """Truncate + neutralise injection-style directives in harvested user prompts."""
+ sanitised = []
+ for p in raw_prompts[:max_count]:
+ clean = _INJ_RE.sub("[FILTERED-DIRECTIVE]", p)[:max_each]
+ sanitised.append(f" - {clean}")
+ return "\n".join(sanitised) or " (none)"
+
+
def _digest_to_prompt(d: SessionDigest) -> str:
# Template lives in the central prompt registry (skillopt_sleep.prompts)
# so the dashboard can display and override it live.
- prompts = "\n".join(f" - {p[:240]}" for p in d.user_prompts[:6]) or " (none)"
+ prompts = _sanitise_prompts(d.user_prompts)
final = (d.assistant_finals[-1][:400] if d.assistant_finals else "(none)")
return prompt_registry.render("miner", {
"__PROJECT__": d.project or "(unknown)",
diff --git a/skillopt_sleep/memory.py b/skillopt_sleep/memory.py
index ef67f364..b1c15117 100644
--- a/skillopt_sleep/memory.py
+++ b/skillopt_sleep/memory.py
@@ -20,6 +20,12 @@
"approve them. Hand-edits outside this block are never touched._"
)
+# Deny-list: reject learned lines that look like credentials or injection (F06).
+_DENY_LEARNED = re.compile(
+ r"(?i)(api[_\-]?key|credential|exfil|ignore\s*instruction|leak|bypass"
+ r"|token\s*=\s*[A-Za-z0-9])"
+)
+
def extract_learned(doc: str) -> str:
s = doc.find(LEARNED_START)
@@ -46,8 +52,10 @@ def _strip_learned(doc: str) -> str:
def set_learned(doc: str, learned_lines: List[str]) -> str:
"""Replace the protected learned region with the given bullet lines."""
+ # Strip lines that match the deny-list (credentials, injection directives).
+ safe_lines = [ln for ln in learned_lines if not _DENY_LEARNED.search(ln)]
base = _strip_learned(doc)
- body = "\n".join(f"- {ln.strip().lstrip('- ').strip()}" for ln in learned_lines if ln.strip())
+ body = "\n".join(f"- {ln.strip().lstrip('- ').strip()}" for ln in safe_lines if ln.strip())
block = (
f"\n\n{LEARNED_START}\n"
f"## Learned preferences & procedures\n\n{_BANNER}\n\n{body}\n"
diff --git a/skillopt_sleep/prompts.py b/skillopt_sleep/prompts.py
index 3553328e..07767c4f 100644
--- a/skillopt_sleep/prompts.py
+++ b/skillopt_sleep/prompts.py
@@ -53,7 +53,9 @@
# Session
project: __PROJECT__
user prompts:
+
__PROMPTS__
+
assistant final (last):
__FINAL__
feedback signals: __FEEDBACK__
diff --git a/skillopt_sleep/staging.py b/skillopt_sleep/staging.py
index 3b2434d4..ed2d6177 100644
--- a/skillopt_sleep/staging.py
+++ b/skillopt_sleep/staging.py
@@ -48,6 +48,12 @@
),
"[REDACTED_PRIVATE_KEY]",
),
+ # Azure SAS tokens (URL query param: ?sig=&...)
+ (re.compile(r"(?i)\bsig=[A-Za-z0-9%+/]{10,}"), "[REDACTED_SAS_SIG]"),
+ # Azure Storage account keys (base64, typically 88 chars)
+ (re.compile(r"(?i)AccountKey=[A-Za-z0-9+/=]{20,}"), "[REDACTED_STORAGE_KEY]"),
+ # Connection-string passwords (Password=...; up to semicolon/quote/whitespace)
+ (re.compile(r"(?i)\bPassword=[^;\"'\s]{6,}"), "[REDACTED_DB_PASS]"),
)
diff --git a/skillopt_sleep/state.py b/skillopt_sleep/state.py
index 1e161571..905443a4 100644
--- a/skillopt_sleep/state.py
+++ b/skillopt_sleep/state.py
@@ -29,6 +29,7 @@ def _now_iso(clock: Optional[float] = None) -> str:
"slow_memory": "", # cross-night consolidated lessons (meta-skill analogue)
"history": [], # list of per-night summaries
"task_archive": [], # capped list of past mined tasks (for associative recall)
+ "last_model_key": "", # "backend::model" string used in the last successful night (F16)
}
@@ -94,3 +95,11 @@ def add_to_archive(self, task_dicts: list, cap: int = 300) -> None:
arc.extend(task_dicts)
if len(arc) > cap:
self.data["task_archive"] = arc[-cap:]
+
+ # ── model-swap tracking (F16) ─────────────────────────────────────────
+ @property
+ def last_model_key(self) -> str:
+ return str(self.data.get("last_model_key", ""))
+
+ def set_last_model_key(self, key: str) -> None:
+ self.data["last_model_key"] = key
diff --git a/skillopt_webui/app.py b/skillopt_webui/app.py
index e4978c5f..c62e9769 100644
--- a/skillopt_webui/app.py
+++ b/skillopt_webui/app.py
@@ -25,6 +25,9 @@
PROJECT_ROOT = Path(__file__).resolve().parent.parent
+# \u2500\u2500\u2500 Per-user state isolation (F13) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n# When auth is enabled, give each user an isolated sub-directory for run\n# outputs and logs. A SHA-256 of the username produces a safe, fixed-length\n# directory name with no path-traversal risk.\n\nimport hashlib as _hashlib\n\n\ndef _user_state_dir(base: Path, user_id: str) -> Path:\n \"\"\"Return a per-user sub-directory under *base*, isolated by hashed user_id.\"\"\"\n slug = _hashlib.sha256(user_id.encode()).hexdigest()[:16]\n d = base / \"_user_state\" / slug\n d.mkdir(parents=True, exist_ok=True)\n return d
+
+
# ─── Config helpers ──────────────────────────────────────────────────────────
def discover_configs() -> list[str]:
@@ -529,8 +532,20 @@ def on_config_change(path):
config_dropdown.change(on_config_change, config_dropdown, config_preview)
+ # F17: per-user rate limit — 5-minute cooldown between launches.
+ import time as _time
+ _last_launch: dict = {}
+ _LAUNCH_COOLDOWN_SECS = 300
+
def on_launch(cfg_path, lr_val, sched, epochs, batch, workers,
slow_update, meta_skill, gate):
+ _user_key = str(cfg_path or "default")
+ _now = _time.time()
+ _elapsed = _now - _last_launch.get(_user_key, 0.0)
+ if _elapsed < _LAUNCH_COOLDOWN_SECS:
+ _remaining = int(_LAUNCH_COOLDOWN_SECS - _elapsed)
+ return f"Rate limit: please wait {_remaining}s before launching another run."
+ _last_launch[_user_key] = _now
overrides = {
"optimizer.learning_rate": lr_val,
"optimizer.lr_scheduler": sched,
@@ -643,15 +658,32 @@ def main():
parser = argparse.ArgumentParser(description="SkillOpt WebUI")
parser.add_argument("--port", type=int, default=7860)
parser.add_argument("--share", action="store_true")
- parser.add_argument("--host", type=str, default="0.0.0.0",
- help="Server host. Use 0.0.0.0 for public access.")
+ parser.add_argument("--host", type=str, default="127.0.0.1",
+ help=(
+ "Server host. Defaults to localhost (127.0.0.1). "
+ "To bind to an external address set SKILLOPT_UI_USER "
+ "and SKILLOPT_UI_PASS environment variables first."
+ ))
args = parser.parse_args()
+ # Require credentials when binding to a non-localhost address (F02).
+ _ui_user = os.environ.get("SKILLOPT_UI_USER", "")
+ _ui_pass = os.environ.get("SKILLOPT_UI_PASS", "")
+ _ui_auth = (_ui_user, _ui_pass) if (_ui_user and _ui_pass) else None
+ if args.host not in ("127.0.0.1", "localhost", "::1") and _ui_auth is None:
+ print(
+ "[webui] ERROR: binding to a non-localhost address requires the "
+ "SKILLOPT_UI_USER and SKILLOPT_UI_PASS environment variables.",
+ file=sys.stderr,
+ )
+ sys.exit(1)
+
app = build_ui()
app.launch(
server_name=args.host,
server_port=args.port,
share=args.share,
+ auth=_ui_auth,
theme=gr.themes.Soft(primary_hue="indigo"),
)