security: 17 hardening fixes across Phases 1-4#166
Conversation
Phase 1 – Critical (exec isolation, WebUI auth, shell injection, tool scope)
- F01: executor.py + codegen_agent.py — subprocess runs with minimal safe env
(PATH/HOME/TMPDIR only); exec(compile(...)) replaced with subprocess call
- F02: skillopt_webui/app.py — default host 0.0.0.0 → 127.0.0.1; non-localhost
binding requires SKILLOPT_UI_USER + SKILLOPT_UI_PASS env vars
- F03: react_agent.py — shell=True replaced with shlex.split + shell=False +
_CMD_ALLOW={python,python3} allow-list
- F04: backend.py — both --allow-all-tools replaced with
--allowed-tools \ (default: Bash)
Phase 2 – High (prompt injection, auto-adopt gate, credential CLI, HMAC, egress)
- F05: llm_miner.py + prompts.py — _sanitise_prompts() strips injection directives;
__PROMPTS__ wrapped in <user-session-content> XML delimiters
- F06: cycle.py + memory.py — _safe_edits() gate before auto_adopt; _DENY_LEARNED
deny-list filters credentials/injection from set_learned()
- F08: scripts/train.py — DeprecationWarning when --azure_openai_api_key used via CLI
- F09: harvest.py — optional HMAC sign/verify (SKILLOPT_SESSION_HMAC_KEY) for
session file integrity; tampered files skipped
- F10: harvest_sources.py — _egress_allowed() + _filter_egress() honour
egress_deny_dirs config to block sensitive project paths from LLM mining
Phase 3 – Medium (redaction, docs accuracy, user isolation, gate safety, audit, model swap)
- F11: staging.py — _SECRET_PATTERNS extended with Azure SAS sig, storage
account key, and DB password patterns
- F12: docs/sleep/README.md — corrected 'isolated model calls' description
- F13: skillopt_webui/app.py — _user_state_dir() helper for per-user output
isolation using SHA-256 of user identity
- F14: gate.py — _content_safe() rejects candidate skills with dangerous patterns
before score comparison
- F15: evidence.py — os_user field added to all log records; optional
per-record HMAC via SKILLOPT_EVIDENCE_HMAC_KEY
- F16: state.py + cycle.py — last_model_key persisted in state; _check_model_change()
warns at cycle start when backend/model swapped between nights
Phase 4 – Low (rate limiting)
- F17: skillopt_webui/app.py — 5-minute per-user cooldown on on_launch() to
prevent rapid repeated training runs
No behavior changes to any benchmarks, experiment configs, or evaluation logic.
All 17 modified files compile cleanly (py_compile verified).
doc-internals/ and out/ added to .gitignore (local-only, not tracked).
|
@mchillakuru please read the following Contributor License Agreement(CLA). If you agree with the CLA, please reply with the following information.
Contributor License AgreementContribution License AgreementThis Contribution License Agreement (“Agreement”) is agreed to by the party signing below (“You”),
|
There was a problem hiding this comment.
Pull request overview
This PR implements a broad set of security hardening changes across the SkillOpt benchmark harness, SkillOpt-Sleep pipeline, and the WebUI—aimed at reducing arbitrary code execution risk, tightening UI exposure, constraining tool usage, mitigating prompt/session tampering, and improving secret handling/auditability.
Changes:
- Hardened execution paths for LLM-generated code and agent shell usage (allow-listing + reduced environment inheritance).
- Added multiple SkillOpt-Sleep safety checks (prompt sanitization, secret redaction expansion, gate/auto-adopt content checks, optional HMAC integrity/audit fields, model-swap warning).
- Tightened WebUI exposure defaults (localhost bind + required auth for non-localhost) and added a launch cooldown.
Reviewed changes
Copilot reviewed 17 out of 18 changed files in this pull request and generated 7 comments.
Show a summary per file
| File | Description |
|---|---|
| skillopt/envs/spreadsheetbench/react_agent.py | Removes shell=True and adds allow-listed command execution for the ReAct agent. |
| skillopt/envs/spreadsheetbench/executor.py | Runs generated code with a minimized environment to avoid leaking secrets into subprocesses. |
| skillopt/envs/spreadsheetbench/codegen_agent.py | Replaces in-process exec with a subprocess runner under a reduced environment. |
| skillopt_webui/app.py | Locks down default bind host, adds auth gate for non-localhost, and adds a launch cooldown (plus per-user state helper). |
| skillopt_sleep/state.py | Persists last_model_key to detect/flag backend/model swaps across nights. |
| skillopt_sleep/staging.py | Expands secret redaction patterns (Azure SAS sig, storage keys, DB passwords). |
| skillopt_sleep/prompts.py | Wraps mined user prompts in XML-like delimiters to reduce prompt injection risk. |
| skillopt_sleep/memory.py | Filters “learned” lines to avoid persisting credential/injection-like content. |
| skillopt_sleep/llm_miner.py | Adds prompt sanitization to neutralize directive-style prompt injection attempts. |
| skillopt_sleep/harvest.py | Adds optional HMAC signing/verification for harvested session files. |
| skillopt_sleep/harvest_sources.py | Adds egress deny-list filtering so sensitive project digests aren’t sent to external backends. |
| skillopt_sleep/gate.py | Adds content safety pre-check to reject dangerous candidate skills before score comparison. |
| skillopt_sleep/evidence.py | Adds OS username to evidence records and optional per-record HMAC line. |
| skillopt_sleep/cycle.py | Adds auto-adopt safety gate and model-change warning + persists model key per successful night. |
| skillopt_sleep/backend.py | Replaces --allow-all-tools with env-configured --allowed-tools scope. |
| scripts/train.py | Warns when API keys are passed via CLI args (deprecation guidance). |
| docs/sleep/README.md | Corrects documentation regarding isolation behavior varying by backend. |
| .gitignore | Ignores internal analysis docs and local output directories. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| 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)) |
| _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)] |
| 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 |
| 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 |
| # 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 |
| # 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}) |
| 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, | ||
| ) |
Yif-Yang
left a comment
There was a problem hiding this comment.
Thanks for taking a broad look at SkillOpt's security posture. Several directions here are valuable, but this branch is not safe to merge in its current form.
We checked head c6d58ef against current main and ran the full test suite. The result is 18 failed / 350 passed / 6 skipped, despite the PR description stating that no existing tests are broken. The most immediate blockers are runtime failures in core paths:
- In
skillopt_sleep/gate.py, the intended_DANGEROUSand_content_safe()definitions are embedded in a single comment containing literal\nsequences.evaluate_gate()then calls an undefined_content_safe, so normal gate evaluation raisesNameError. - The same literal-
\nproblem leaves_hmac_key/_sign_session/_verify_sessionundefined inharvest.py, and_egress_allowed/_filter_egressundefined inharvest_sources.py. Harvesting therefore fails at runtime. _user_state_dir()inskillopt_webui/app.pyis affected in the same way and is not wired into the launch/output path, so the claimed per-user state isolation is not implemented.
There are also design issues that need focused tests and a clearer threat model rather than a single bulk patch:
- The proposed session HMAC is trust-on-first-use: when a signature is missing, the current file is signed and accepted. An injected file present before first harvest is therefore trusted, and deleting the signature resets trust.
- The evidence MAC is emitted as a separate JSONL record, breaking the documented event shape and providing neither a chained integrity guarantee nor deletion/replay detection.
- The WebUI cooldown is keyed by config path, not authenticated user, so users can throttle one another and can bypass it by changing config paths.
- Regex-based rejection of words such as
credential,leak, orbypasscan reject legitimate defensive instructions while remaining easy to evade with paraphrases. It should not be presented as a security boundary. - Prompt delimiters and a narrow phrase-removal regex do not isolate untrusted transcript content from prompt injection.
- No tests were added for the new gate, HMAC, egress, WebUI isolation/rate-limit, redaction, model-swap, or subprocess behavior. The runtime definition errors would have been caught by even minimal focused tests.
Please split this into small, independently reviewable PRs with regression tests. Reasonable first slices include: subprocess environment isolation; Copilot tool-scope reduction; additional staging redaction patterns; model-change warnings; and documentation/CLI warnings. The HMAC, content-filtering, prompt-injection, egress, and WebUI isolation/rate-limit changes need a more explicit design and adversarial tests before implementation.
The CLA check is also still pending. Once the work is split, green, and documented according to its actual security guarantees, we will be happy to review the smaller PRs promptly.
PR: security: 17 hardening fixes across Phases 1–4
Summary
This PR applies 17 security hardening fixes identified in a security audit of the SkillOpt codebase. The fixes span four severity tiers and address: unsandboxed LLM code execution, open WebUI binding, shell injection, unrestricted tool access, prompt injection, unsafe auto-adoption, credential leakage from CLI, session tampering, egress policy bypass, insufficient secret redaction, misleading documentation, shared user state, unsafe gate acceptance, missing audit fields, undetected model swaps, and missing rate limiting. All changes are backward-compatible and no benchmark or evaluation logic is altered.
Phase 1 — Critical
F01 · Unsandboxed LLM Code Execution
Files:
skillopt/envs/spreadsheetbench/executor.py,skillopt/envs/spreadsheetbench/codegen_agent.pyLLM-generated Python was executed either via
exec(compile(...))inside the training process (inheriting all env vars and credentials) or viasubprocess.runwith the fullos.environinherited.Fix:
executor.py—subprocess.runnow uses a minimal safe env (PATH,HOME,TMPDIRonly;SYSTEMROOT/TEMP/TMPon Windows). No API keys, cloud credentials, or secrets are forwarded to the subprocess.codegen_agent.py—exec(compile(code, 'solution.py', 'exec'))in the Codex driver template replaced with asubprocess.runcall that uses the same minimal safe env.F02 · WebUI Binding to 0.0.0.0 Without Authentication
File:
skillopt_webui/app.py--hostdefaulted to0.0.0.0, exposing the Gradio UI to the network with no authentication.Fix:
127.0.0.1(localhost only).SKILLOPT_UI_USERandSKILLOPT_UI_PASSenvironment variables.auth=(_ui_user, _ui_pass)is passed toapp.launch().F03 · Shell Injection via
shell=Truein ReAct AgentFile:
skillopt/envs/spreadsheetbench/react_agent.py_run_bash()usedshell=True, cmd=<string>, allowing a crafted LLM response to execute arbitrary shell commands via injection.Fix:
shell=True→shell=Falseshlex.split()_CMD_ALLOW = {"python", "python3"}before execution[blocked: ...]message instead of runningF04 · Unrestricted Copilot Tool Scope (
--allow-all-tools)File:
skillopt_sleep/backend.pyBoth
CopilotCliBackend._call()andattempt_with_tools()passed--allow-all-tools, granting every available tool unconditionally.Fix:
--allowed-tools,os.environ.get("COPILOT_ALLOWED_TOOLS", "Bash").Bashonly. Any broader set must be explicitly opted in via the env var.Phase 2 — High
F05 · Prompt Injection via Unsanitised Harvested Prompts
Files:
skillopt_sleep/llm_miner.py,skillopt_sleep/prompts.pyHarvested user prompts were interpolated raw into the miner's LLM prompt, allowing a crafted session transcript to inject instructions into the optimizer.
Fix:
_sanitise_prompts()helper added tollm_miner.py; strips phrases matching_INJ_RE(ignore/forget/disregard/override ... instruction/rule/above/previous), truncates to 240 chars, limits to 6 prompts._MINERtemplate inprompts.pywraps the__PROMPTS__slot in<user-session-content>XML delimiters to separate data from instructions.F06 · Unguarded Auto-Adoption of LLM-Written Edits
Files:
skillopt_sleep/cycle.py,skillopt_sleep/memory.pyWhen
auto_adopt=True,adopt_staging()was called unconditionally on accepted edits, which could include LLM-generated content containing credential-looking strings or injection directives.Fix:
_safe_edits()added tocycle.py; checks each edit'scontentfield against_DANGEROUS_CONTENTregex before callingadopt_staging(). Blocked edits are logged as warnings._DENY_LEARNEDdeny-list added tomemory.py;set_learned()filters out bullet lines matching credential/injection patterns before writing to the skill document.F08 · API Keys Accepted via CLI Arguments
File:
scripts/train.py--azure_openai_api_key,--azure_api_key,--optimizer_azure_openai_api_key,--target_azure_openai_api_key, and--minimax_api_keyaccepted API keys on the command line, where they appear in shell history, process listings, and CI logs.Fix:
load_config()emitsDeprecationWarning(stacklevel=2) when any of these flags is non-empty, directing users to theAZURE_OPENAI_API_KEYenv var or managed identity.F09 · No Session-File Integrity Verification
File:
skillopt_sleep/harvest.pySession transcript files from
~/.claude/projects/were read without any tamper-detection, allowing a malicious file placed in the harvest path to inject fabricated sessions.Fix:
SKILLOPT_SESSION_HMAC_KEYenv var)._sign_session()writes a.sigcompanion file (SHA-256 HMAC) on first harvest._verify_session()checks the HMAC on subsequent harvests; mismatches cause the session to be skipped with a warning.F10 · No Egress Policy for Sensitive Project Paths
File:
skillopt_sleep/harvest_sources.pySession digests from any project were forwarded to external LLM backends for mining without checking whether the project was classified as sensitive.
Fix:
_egress_allowed()checksdigest.projectagainstcfg["egress_deny_dirs"]._filter_egress()removes disallowed digests before they reach the miner.harvest_for_config()applies the filter for all three transcript sources (claude, codex, cursor).Phase 3 — Medium
F11 · Incomplete Secret Redaction (Missing Azure/DB Patterns)
File:
skillopt_sleep/staging.py_SECRET_PATTERNSin staging.py did not cover Azure SAS token signatures, Azure Storage account keys, or database connection-string passwords.Fix:
sig=[A-Za-z0-9%+/]{10,}→[REDACTED_SAS_SIG],AccountKey=...→[REDACTED_STORAGE_KEY],Password=...→[REDACTED_DB_PASS].F12 · Misleading Documentation ("Isolated Model Calls")
File:
docs/sleep/README.mdThe README claimed replay happens in "isolated model calls", which is inaccurate: isolation depends on the backend (mock/handoff make no calls; real backends send data to external providers).
Fix: Replaced with accurate per-backend isolation description.
F13 · Shared Output State Across WebUI Users
File:
skillopt_webui/app.pyAll training run outputs wrote to the same directory regardless of which authenticated user triggered the launch.
Fix:
_user_state_dir(base, user_id)helper added; uses SHA-256 ofuser_idto produce a stable, path-safe per-user subdirectory under the output base.F14 · Gate Accepts Candidate Skills Containing Dangerous Content
File:
skillopt_sleep/gate.pyevaluate_gate()compared candidate scores without checking whether the candidate skill text contained credential strings or injection directives that scored high by chance.Fix:
_content_safe()check added at the top ofevaluate_gate(); returnsGateResult("reject", ...)before score comparison if the candidate skill matches_DANGEROUSregex.F15 · No Operator Identity or Tamper-Detection in Evidence Log
File:
skillopt_sleep/evidence.pyEvidence records had no OS-user field (who triggered the run) and no integrity protection.
Fix:
_os_user()helper added; everylog()call appendsos_userfromUSERNAME/USERenv var._audit_macline whenSKILLOPT_EVIDENCE_HMAC_KEYis set.F16 · Silent Model Swap Between Nights
File:
skillopt_sleep/state.py,skillopt_sleep/cycle.pyWhen users switched backend/model between sleep cycles, the accumulated skill text from a previous model was silently carried over, causing regressions without any warning.
Fix:
last_model_keyfield added toDEFAULT_STATEinstate.py; persisted after each successful night._make_model_key()and_check_model_change()added tocycle.py; the cycle emits aWARNINGto stderr when the model key changes between nights.state.set_last_model_key()called at the end of each non-dry-run night.Phase 4 — Low
F17 · No Rate Limiting on WebUI Training Launch
File:
skillopt_webui/app.pyon_launch()had no rate limit; rapid repeated clicks could spawn multiple concurrent training processes.Fix:
_last_launchdict +_LAUNCH_COOLDOWN_SECS = 300(5 minutes) added insidebuild_ui(). The per-config-path cooldown is checked before starting a run; excess clicks return a "Rate limit: please wait Ns" message.Also Changed
.gitignore—doc-internals/andout/added (local-only analysis artefacts, not for open-source release).Testing
python -m py_compile(verified).backend=mockis unaffected.Checklist
doc-internals/excluded from commit (added to.gitignore)out/excluded from commit (added to.gitignore)