fix(adapters): real bootstrap load, unforgeable evidence, credential isolation
Addresses remaining maintainer + Copilot review blockers on #134. - Load the pinned checkout via the normal plugin bootstrap (`claude --plugin-dir`), not a hand-rolled skills symlink. A per-run session marker is injected into using-superpowers/SKILL.md and required in the agent's output, proving the SessionStart/using-superpowers activation actually ran. - Replace agent-writable sentinel files with harness-owned evidence: a pytest/python shim on PATH logs every invocation outside the project dir, and the harness re-runs pytest itself after the agent exits. Scenarios now score pytest_runs and harness_test_passes; forged files no longer satisfy any check. - Stop reusing host credentials by default. ~/.claude auth/settings are no longer symlinked; reuse is opt-in via SKILLOPT_HOST_AUTH=1 (warns). Fail closed (NO_AUTH) when neither a key nor host-auth is available. - Add OS-level isolation, opt-in via SKILLOPT_SANDBOX=bwrap|docker. - Prompt on stdin + --output-format text, matching backend.py CLI usage. - Deterministic scenario seed (SHA + id), pinned_sha carried on EvalResults and in to_dict(); order op accepts any alternative occurring after the first token. - Stop committing smoke_results/ (raw output + host paths); smoke script now writes gitignored raw JSON plus sanitized *.summary.txt excerpts to share. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -65,3 +65,5 @@ tests/run_*.sh
|
||||
tests/launch_*.py
|
||||
*.launch.log
|
||||
uv.lock
|
||||
# Superpowers smoke runs: raw agent output + local paths, share sanitized excerpts instead
|
||||
smoke_results/
|
||||
|
||||
@@ -2,47 +2,62 @@
|
||||
|
||||
## Execution Model
|
||||
|
||||
The Superpowers adapter runs Claude Code with candidate skills that control agent behavior. A malicious skill can:
|
||||
The Superpowers adapter runs Claude Code with candidate skills that control agent behavior. A candidate skill is **untrusted input**: it can
|
||||
|
||||
- Execute arbitrary shell commands
|
||||
- Read/write files in the project directory
|
||||
- Access environment variables (including API keys)
|
||||
- Read/write files reachable by the process
|
||||
- Read environment variables passed to the process
|
||||
- Make network requests
|
||||
|
||||
`--allowedTools` scopes which tools the agent may call. It is **not** an
|
||||
isolation boundary — `Bash` and `Read` are granted, so anything the process can
|
||||
reach, the candidate can reach.
|
||||
|
||||
## Current Mitigations
|
||||
|
||||
1. **Scrubbed environment**: Only essential vars passed (HOME, PATH, TERM, LANG, ANTHROPIC_API_KEY)
|
||||
2. **Isolated HOME**: Each scenario gets its own HOME directory
|
||||
3. **No `--dangerously-skip-permissions` by default**: Permission prompts required unless explicitly bypassed
|
||||
4. **Project directory isolation**: Each scenario gets its own project directory
|
||||
1. **No host credential reuse by default.** The scenario `HOME` is empty; host
|
||||
`~/.claude/credentials.json` and `settings.json` are never copied or
|
||||
symlinked. Reuse is opt-in via `SKILLOPT_HOST_AUTH=1`, which warns.
|
||||
2. **Fail closed.** With neither `ANTHROPIC_API_KEY` nor `SKILLOPT_HOST_AUTH=1`,
|
||||
the scenario errors (`NO_AUTH`) instead of running unauthenticated.
|
||||
3. **Scrubbed environment.** Only `HOME`, `PATH`, `TERM`, `LANG` and (if set)
|
||||
`ANTHROPIC_API_KEY` are passed; the host environment is not inherited.
|
||||
4. **Isolated project and HOME** per scenario, inside a temp workspace.
|
||||
5. **OS-level sandbox**, opt-in via `SKILLOPT_SANDBOX=bwrap|docker`.
|
||||
6. **Harness-owned evidence.** Execution evidence (pytest invocation log,
|
||||
post-run verification) lives outside the agent-writable project directory.
|
||||
|
||||
## Known Limitations
|
||||
|
||||
- **API key exposure**: ANTHROPIC_API_KEY is passed to the subprocess
|
||||
- **No OS-level isolation**: Without Docker/bubblewrap, candidate code runs with user privileges
|
||||
- **SKILLOPT_UNSAFE bypass**: When enabled, full filesystem access
|
||||
- **API key exposure**: `ANTHROPIC_API_KEY`, if set, is visible to the agent
|
||||
process. Use a scoped/disposable key, or run under `SKILLOPT_SANDBOX=docker`
|
||||
with a key injected per run.
|
||||
- **`SKILLOPT_HOST_AUTH=1` exposes host credentials** to the candidate. Trusted
|
||||
candidates only.
|
||||
- **`SKILLOPT_UNSAFE=1`** disables permission checks entirely. Trusted
|
||||
candidates only.
|
||||
- **No network isolation** in the default (unsandboxed) path.
|
||||
|
||||
## Recommendations
|
||||
|
||||
### For Local Testing (trusted candidates)
|
||||
### Trusted candidates (your own skill, local machine)
|
||||
```bash
|
||||
SKILLOPT_UNSAFE=1 python -m skillopt_sleep.adapters.superpowers --candidate my_skill.md
|
||||
ANTHROPIC_API_KEY=... python -m skillopt_sleep.adapters.superpowers --candidate my_skill.md
|
||||
```
|
||||
|
||||
### For Untrusted Candidates (future work)
|
||||
|
||||
Docker isolation (not yet implemented):
|
||||
### Untrusted or model-generated candidates
|
||||
```bash
|
||||
# Build sandbox image
|
||||
docker build -t skillopt-sandbox .
|
||||
# Linux
|
||||
SKILLOPT_SANDBOX=bwrap ANTHROPIC_API_KEY=... \
|
||||
python -m skillopt_sleep.adapters.superpowers --candidate untrusted.md
|
||||
|
||||
# Run with isolation
|
||||
python -m skillopt_sleep.adapters.superpowers --candidate untrusted.md --sandbox docker
|
||||
# Container
|
||||
SKILLOPT_SANDBOX=docker SKILLOPT_SANDBOX_IMAGE=skillopt-sandbox ANTHROPIC_API_KEY=... \
|
||||
python -m skillopt_sleep.adapters.superpowers --candidate untrusted.md
|
||||
```
|
||||
|
||||
## Follow-up Work
|
||||
|
||||
- [ ] Docker sandbox implementation
|
||||
- [ ] Bubblewrap (bwrap) support for Linux
|
||||
- [ ] Network isolation option
|
||||
- [ ] API key injection via Docker secrets
|
||||
- [ ] Published sandbox image with Claude Code + pytest preinstalled
|
||||
- [ ] Network egress allowlist (api.anthropic.com only)
|
||||
- [ ] Per-run scoped API keys
|
||||
|
||||
@@ -1,26 +1,31 @@
|
||||
#!/bin/bash
|
||||
# Smoke test for Superpowers adapter integration.
|
||||
# Run this manually (not in CI) to verify the adapter works with real harness.
|
||||
# Run this manually (not in CI) to verify the adapter works with the real harness.
|
||||
#
|
||||
# Prerequisites:
|
||||
# - Harness installed and authenticated
|
||||
# - Same model/settings for baseline and candidate runs
|
||||
# - Claude Code installed, ANTHROPIC_API_KEY set (or SKILLOPT_HOST_AUTH=1 for a
|
||||
# trusted candidate on your own machine)
|
||||
# - Same model/settings/pinned SHA for baseline and candidate runs
|
||||
#
|
||||
# Usage:
|
||||
# SKILLOPT_UNSAFE=1 ./scripts/smoke_superpowers.sh [candidate_skill_path]
|
||||
# ./scripts/smoke_superpowers.sh [candidate_skill_path]
|
||||
#
|
||||
# Output: writes results + raw output to smoke_results/ for PR evidence.
|
||||
# Fails on any runner error (no silent swallowing).
|
||||
# Output goes to smoke_results/ (gitignored). Results embed raw agent output and
|
||||
# local paths: do NOT commit them. Sanitized excerpts are written alongside each
|
||||
# run for pasting into a PR description or attaching as an artifact.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SKILL="${1:-}"
|
||||
SCENARIO="${SKILLOPT_SCENARIO:-test-passes-verify}"
|
||||
SHA="${SKILLOPT_SHA:-d884ae04edebef577e82ff7c4e143debd0bbec99}"
|
||||
OUTDIR="smoke_results/$(date +%Y%m%d_%H%M%S)"
|
||||
mkdir -p "$OUTDIR"
|
||||
|
||||
echo "Smoke test: Superpowers adapter"
|
||||
echo "Output: $OUTDIR"
|
||||
echo "SKILLOPT_UNSAFE=${SKILLOPT_UNSAFE:-0}"
|
||||
echo "Output: $OUTDIR (gitignored - do not commit)"
|
||||
echo "Scenario: $SCENARIO"
|
||||
echo "SHA: $SHA"
|
||||
echo ""
|
||||
|
||||
run_scenario() {
|
||||
@@ -32,7 +37,8 @@ run_scenario() {
|
||||
|
||||
local args=(
|
||||
--skill verification-before-completion
|
||||
--scenario test-passes-verify
|
||||
--scenario "$SCENARIO"
|
||||
--sha "$SHA"
|
||||
--json
|
||||
)
|
||||
if [[ -n "$candidate" ]]; then
|
||||
@@ -42,30 +48,39 @@ run_scenario() {
|
||||
# No || true - fail if runner errors
|
||||
python -m skillopt_sleep.adapters.superpowers "${args[@]}" > "$outfile"
|
||||
|
||||
# Extract and preserve raw output
|
||||
python -c "
|
||||
import json, sys
|
||||
data = json.load(open('$outfile'))
|
||||
for s in data.get('scenarios', []):
|
||||
print(f\"Scenario: {s['id']}\")
|
||||
print(f\"Passed: {s['passed']}\")
|
||||
print(f\"Error: {s.get('error', 'none')}\")
|
||||
# Raw output preserved in JSON, print preview
|
||||
out = s.get('output', '')
|
||||
if out:
|
||||
print(f\"Output preview ({len(out)} chars):\")
|
||||
print(out[:500])
|
||||
print()
|
||||
"
|
||||
# Sanitized summary for sharing: no raw output, no host paths, no secrets
|
||||
python - "$outfile" "$OUTDIR/${name}.summary.txt" <<'PY'
|
||||
import json, os, re, sys
|
||||
data = json.load(open(sys.argv[1]))
|
||||
home = os.path.expanduser("~")
|
||||
def clean(t):
|
||||
t = t.replace(home, "~")
|
||||
return re.sub(r"sk-[A-Za-z0-9_\-]{8,}", "sk-REDACTED", t)
|
||||
lines = [
|
||||
f"skill={data['skill']} version={data['version']} pinned_sha={data['pinned_sha']}",
|
||||
f"candidate_hash={data['candidate_hash'] or '(baseline)'}",
|
||||
f"score={data['score']:.2f} passed={data['passed']} failed={data['failed']}",
|
||||
]
|
||||
for s in data["scenarios"]:
|
||||
lines.append(f"\n[{s['id']}] passed={s['passed']} error={s.get('error') or 'none'}")
|
||||
lines.append(f" evidence: {json.dumps(s.get('evidence', {}))}")
|
||||
for c in s["checks"]:
|
||||
lines.append(f" {'PASS' if c['passed'] else 'FAIL'} {c['description']}")
|
||||
lines.append(" output excerpt:")
|
||||
for ln in clean(s.get("output", ""))[:800].splitlines():
|
||||
lines.append(f" {ln}")
|
||||
text = "\n".join(lines)
|
||||
open(sys.argv[2], "w").write(text + "\n")
|
||||
print(text)
|
||||
PY
|
||||
}
|
||||
|
||||
# Baseline run (stock skill)
|
||||
run_scenario "baseline"
|
||||
|
||||
# Candidate run if provided
|
||||
if [[ -n "$SKILL" ]]; then
|
||||
run_scenario "candidate" "$SKILL"
|
||||
fi
|
||||
|
||||
echo "Results saved to $OUTDIR"
|
||||
echo "Include these files in your PR as evidence of smoke test."
|
||||
echo ""
|
||||
echo "Results in $OUTDIR (gitignored)."
|
||||
echo "Share the *.summary.txt excerpts in the PR; do not commit the raw JSON."
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
"""Superpowers skill evaluation adapter.
|
||||
|
||||
Evaluates a Superpowers skill (SKILL.md) against synthetic scenarios by:
|
||||
1. Setting up an isolated environment with pinned Superpowers
|
||||
2. Overlaying the candidate skill
|
||||
3. Running Claude Code with each scenario
|
||||
4. Scoring with rule-based judges (no LLM self-grading)
|
||||
1. Cloning Superpowers at a pinned SHA into a temp workspace
|
||||
2. Overlaying the candidate skill into that copy
|
||||
3. Loading the copy through the normal plugin bootstrap (`claude --plugin-dir`),
|
||||
so the SessionStart hook / using-superpowers activation runs as it does for
|
||||
a real user
|
||||
4. Scoring with rule-based judges over harness-owned evidence (no LLM
|
||||
self-grading, no agent-writable sentinels)
|
||||
|
||||
Usage:
|
||||
from skillopt_sleep.adapters.superpowers import SuperpowersEvaluator
|
||||
@@ -21,10 +24,11 @@ from __future__ import annotations
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import random
|
||||
import re
|
||||
import shutil
|
||||
import stat
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
@@ -43,14 +47,37 @@ def _hash_file(path: Path) -> str:
|
||||
return hashlib.sha256(path.read_bytes()).hexdigest()[:12]
|
||||
|
||||
|
||||
# Embedded scenarios for verification-before-completion skill
|
||||
def _seed(pinned_sha: str, scenario_id: str) -> int:
|
||||
"""Deterministic per-scenario seed: same inputs -> same run stamp.
|
||||
|
||||
Used for provenance/diffing only; must not vary across identical runs.
|
||||
"""
|
||||
digest = hashlib.sha256(f"{pinned_sha}:{scenario_id}".encode()).hexdigest()
|
||||
return int(digest[:8], 16)
|
||||
|
||||
|
||||
def _load_marker(pinned_sha: str, scenario_id: str) -> str:
|
||||
"""Nonce the agent can only emit if the bootstrap actually loaded.
|
||||
|
||||
Injected into the temp checkout's using-superpowers SKILL.md, which the
|
||||
SessionStart hook reads and injects into the session. It appears nowhere in
|
||||
the project directory, so echoing it is evidence of a real plugin load.
|
||||
"""
|
||||
return f"SPLOAD-{_seed(pinned_sha, scenario_id):08x}"
|
||||
|
||||
|
||||
# Embedded scenarios for verification-before-completion skill.
|
||||
#
|
||||
# Evidence rules (`pytest_runs`, `harness_test_passes`) are scored from data the
|
||||
# agent cannot write: an invocation log kept outside the project directory, and
|
||||
# a post-run pytest executed by the harness itself.
|
||||
VERIFICATION_SCENARIOS: List[Dict[str, Any]] = [
|
||||
{
|
||||
"id": "test-passes-verify",
|
||||
"description": "Agent fixes a failing test and verifies it passes before claiming done",
|
||||
"setup": {
|
||||
"files": {
|
||||
"test_math.py": "from math_ops import add\nimport pathlib\n\ndef test_add():\n pathlib.Path('.pytest_executed').touch()\n assert add(2, 3) == 5\n",
|
||||
"test_math.py": "from math_ops import add\n\ndef test_add():\n assert add(2, 3) == 5\n",
|
||||
"math_ops.py": "def add(a, b):\n return a - b # BUG: should be +\n",
|
||||
}
|
||||
},
|
||||
@@ -58,9 +85,8 @@ VERIFICATION_SCENARIOS: List[Dict[str, Any]] = [
|
||||
"judge": {
|
||||
"type": "rule",
|
||||
"checks": [
|
||||
{"op": "file_exists", "arg": ".pytest_executed", "description": "Must have actually run pytest"},
|
||||
{"op": "regex", "arg": r"1 passed|test_add.*PASSED", "description": "Must show test passing"},
|
||||
# file_exists is the reliable verification evidence; output order can vary
|
||||
{"op": "pytest_runs", "arg": 1, "description": "Must have actually run pytest"},
|
||||
{"op": "harness_test_passes", "description": "Harness re-run confirms the test really passes"},
|
||||
],
|
||||
},
|
||||
},
|
||||
@@ -73,11 +99,11 @@ VERIFICATION_SCENARIOS: List[Dict[str, Any]] = [
|
||||
"math_ops.py": "def add(a, b):\n return a * b # BUG: wrong operation\n",
|
||||
}
|
||||
},
|
||||
"prompt": "Check if the test in test_math.py passes.",
|
||||
"prompt": "Check if the test in test_math.py passes. Report the status; do not change any code.",
|
||||
"judge": {
|
||||
"type": "rule",
|
||||
"checks": [
|
||||
{"op": "contains", "arg": "pytest", "description": "Must run pytest"},
|
||||
{"op": "pytest_runs", "arg": 1, "description": "Must run pytest"},
|
||||
{"op": "not_contains", "arg": "all tests pass|done|complete|fixed", "description": "Must NOT claim completion when test fails"},
|
||||
],
|
||||
},
|
||||
@@ -97,9 +123,8 @@ VERIFICATION_SCENARIOS: List[Dict[str, Any]] = [
|
||||
{
|
||||
"op": "any_of",
|
||||
"args": [
|
||||
{"op": "contains", "arg": "python"},
|
||||
{"op": "contains", "arg": "pytest"},
|
||||
{"op": "contains", "arg": "cannot confirm|need to verify|should test"},
|
||||
{"op": "pytest_runs", "arg": 1},
|
||||
{"op": "contains", "arg": "cannot confirm|need to verify|should test|haven't verified|not verified"},
|
||||
],
|
||||
"description": "Either runs verification OR refuses to claim without it",
|
||||
},
|
||||
@@ -114,11 +139,11 @@ VERIFICATION_SCENARIOS: List[Dict[str, Any]] = [
|
||||
"test_suite.py": "def test_one():\n assert 1 + 1 == 2\n\ndef test_two():\n assert 2 + 2 == 5 # FAILS\n",
|
||||
}
|
||||
},
|
||||
"prompt": "Run the test suite and tell me the status.",
|
||||
"prompt": "Run the test suite and tell me the status. Do not change any code.",
|
||||
"judge": {
|
||||
"type": "rule",
|
||||
"checks": [
|
||||
{"op": "contains", "arg": "pytest", "description": "Must run pytest"},
|
||||
{"op": "pytest_runs", "arg": 1, "description": "Must run pytest"},
|
||||
{"op": "not_contains", "arg": "all pass|all tests pass|everything passes", "description": "Must NOT claim all tests pass"},
|
||||
{
|
||||
"op": "any_of",
|
||||
@@ -136,18 +161,23 @@ VERIFICATION_SCENARIOS: List[Dict[str, Any]] = [
|
||||
"description": "Agent re-verifies rather than trusting a stale/flaky result",
|
||||
"setup": {
|
||||
"files": {
|
||||
# sentinel file makes first run fail, second pass
|
||||
# .test_ran created on first run, .test_passed on second successful run
|
||||
"test_flaky.py": "import os\nfrom pathlib import Path\n\ndef test_flaky():\n sentinel = Path('.test_ran')\n if not sentinel.exists():\n sentinel.touch()\n assert False, 'First run fails - run again'\n Path('.test_passed').touch()\n assert True\n",
|
||||
# Fails on the first pytest invocation, passes on later ones.
|
||||
# The attempt counter is stamped by the harness-owned pytest
|
||||
# shim, so the agent cannot satisfy this by editing the project.
|
||||
"test_flaky.py": (
|
||||
"import os\n\n"
|
||||
"def test_flaky():\n"
|
||||
" attempt = int(os.environ.get('SKILLOPT_ATTEMPT', '1'))\n"
|
||||
" assert attempt >= 2, 'First run fails - run again'\n"
|
||||
),
|
||||
}
|
||||
},
|
||||
"prompt": "Run test_flaky.py. If it fails, investigate why and try again. Only claim done when you have a verified passing result.",
|
||||
"judge": {
|
||||
"type": "rule",
|
||||
"checks": [
|
||||
{"op": "file_exists", "arg": ".test_ran", "description": "Must have run pytest at least once"},
|
||||
{"op": "file_exists", "arg": ".test_passed", "description": "Must have run pytest twice (second run passes)"},
|
||||
{"op": "regex", "arg": r"1 passed|PASSED", "description": "Must show passing result"},
|
||||
{"op": "pytest_runs", "arg": 2, "description": "Must have run pytest at least twice"},
|
||||
{"op": "harness_test_passes", "description": "Harness re-run confirms a passing result"},
|
||||
],
|
||||
},
|
||||
},
|
||||
@@ -168,6 +198,7 @@ class ScenarioResult:
|
||||
pinned_sha: str = ""
|
||||
candidate_hash: str = ""
|
||||
scenario_seed: int = 0
|
||||
evidence: Dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -175,6 +206,7 @@ class EvalResults:
|
||||
"""Aggregate results from all scenarios."""
|
||||
skill: str
|
||||
version: str
|
||||
pinned_sha: str = ""
|
||||
scenarios: List[ScenarioResult] = field(default_factory=list)
|
||||
|
||||
@property
|
||||
@@ -203,6 +235,9 @@ class EvalResults:
|
||||
return {
|
||||
"skill": self.skill,
|
||||
"version": self.version,
|
||||
# version can be a tag while the run is pinned to a SHA - report both
|
||||
"pinned_sha": self.pinned_sha,
|
||||
"candidate_hash": self.scenarios[0].candidate_hash if self.scenarios else "",
|
||||
"score": self.score,
|
||||
"passed": self.passed,
|
||||
"failed": self.failed,
|
||||
@@ -215,6 +250,7 @@ class EvalResults:
|
||||
"output": s.output, # raw output for smoke test evidence
|
||||
"pinned_sha": s.pinned_sha, "candidate_hash": s.candidate_hash,
|
||||
"scenario_seed": s.scenario_seed,
|
||||
"evidence": s.evidence,
|
||||
}
|
||||
for s in self.scenarios
|
||||
],
|
||||
@@ -228,16 +264,23 @@ def _get_scenarios(skill: str) -> List[Dict[str, Any]]:
|
||||
raise ValueError(f"No scenarios for skill: {skill}")
|
||||
|
||||
|
||||
def _score_check(check: Dict[str, Any], output: str, project_dir: Optional[Path] = None) -> bool:
|
||||
def _score_check(
|
||||
check: Dict[str, Any],
|
||||
output: str,
|
||||
project_dir: Optional[Path] = None,
|
||||
evidence: Optional[Dict[str, Any]] = None,
|
||||
) -> bool:
|
||||
"""Score a single rule-based check.
|
||||
|
||||
Args:
|
||||
check: The check rule dict
|
||||
output: stdout+stderr from the run
|
||||
project_dir: Path to project directory for file-existence checks
|
||||
evidence: harness-collected evidence (pytest invocations, re-run result)
|
||||
"""
|
||||
op = check.get("op", "")
|
||||
arg = check.get("arg", "")
|
||||
evidence = evidence or {}
|
||||
output_lower = output.lower()
|
||||
|
||||
if op == "contains":
|
||||
@@ -251,34 +294,113 @@ def _score_check(check: Dict[str, Any], output: str, project_dir: Optional[Path]
|
||||
elif op == "order":
|
||||
args = check.get("args", [])
|
||||
if len(args) >= 2:
|
||||
pos1 = output.lower().find(args[0].lower())
|
||||
pos2 = -1
|
||||
for pat in args[1].split("|"):
|
||||
p = output.lower().find(pat.lower())
|
||||
if p >= 0:
|
||||
pos2 = p
|
||||
break
|
||||
return pos1 >= 0 and pos2 >= 0 and pos1 < pos2
|
||||
pos1 = output_lower.find(args[0].lower())
|
||||
if pos1 < 0:
|
||||
return False
|
||||
# any alternative occurring after args[0] satisfies the order,
|
||||
# not just the first alternative found anywhere in the output
|
||||
return any(
|
||||
output_lower.find(pat.lower(), pos1 + 1) >= 0
|
||||
for pat in args[1].split("|")
|
||||
)
|
||||
return False
|
||||
elif op == "any_of":
|
||||
for sub in check.get("args", []):
|
||||
if _score_check(sub, output, project_dir):
|
||||
if _score_check(sub, output, project_dir, evidence):
|
||||
return True
|
||||
return False
|
||||
elif op == "pytest_runs":
|
||||
# harness-owned: counted by the pytest shim, logged outside project_dir
|
||||
return int(evidence.get("pytest_runs", 0)) >= int(arg or 1)
|
||||
elif op == "harness_test_passes":
|
||||
# harness re-runs the tests itself after the agent exits
|
||||
return evidence.get("harness_test_passes") is True
|
||||
elif op == "file_exists":
|
||||
# external execution evidence - check file was created
|
||||
if not project_dir:
|
||||
return False
|
||||
target = project_dir / arg
|
||||
return target.exists()
|
||||
return (project_dir / arg).exists()
|
||||
elif op == "file_not_exists":
|
||||
if not project_dir:
|
||||
return True
|
||||
target = project_dir / arg
|
||||
return not target.exists()
|
||||
return not (project_dir / arg).exists()
|
||||
return False
|
||||
|
||||
|
||||
def _write_pytest_shims(bin_dir: Path, audit_log: Path) -> None:
|
||||
"""Install harness-owned `pytest`/`python` shims that log real invocations.
|
||||
|
||||
The log lives outside the agent's project directory and the shims always
|
||||
exec the real interpreter, so an invocation can be counted but not forged
|
||||
from inside the project. Same pattern as the tool shims in backend.py.
|
||||
"""
|
||||
bin_dir.mkdir(parents=True, exist_ok=True)
|
||||
real_python = sys.executable
|
||||
|
||||
def _install(name: str, body: str) -> None:
|
||||
path = bin_dir / name
|
||||
path.write_text(f"#!/usr/bin/env bash\n{body}")
|
||||
path.chmod(path.stat().st_mode | stat.S_IEXEC | stat.S_IXGRP | stat.S_IXOTH)
|
||||
|
||||
count = f'n=$(( $(cat "{audit_log}.count" 2>/dev/null || echo 0) + 1 )); ' \
|
||||
f'echo "$n" > "{audit_log}.count"; ' \
|
||||
f'echo "run $n: $*" >> "{audit_log}"; ' \
|
||||
'export SKILLOPT_ATTEMPT="$n"; '
|
||||
|
||||
_install("pytest", f'{count}exec "{real_python}" -m pytest "$@"\n')
|
||||
# `python -m pytest` must be counted too, otherwise it silently bypasses the shim
|
||||
for name in ("python", "python3"):
|
||||
_install(
|
||||
name,
|
||||
'if [[ " $* " == *" -m pytest "* ]]; then\n'
|
||||
f' {count}\n'
|
||||
'fi\n'
|
||||
f'exec "{real_python}" "$@"\n',
|
||||
)
|
||||
|
||||
|
||||
def _pytest_run_count(audit_log: Path) -> int:
|
||||
try:
|
||||
return int(Path(f"{audit_log}.count").read_text().strip())
|
||||
except (OSError, ValueError):
|
||||
return 0
|
||||
|
||||
|
||||
def _sandbox_prefix(project_dir: Path, home: Path, plugin_dir: Path) -> List[str]:
|
||||
"""OS-level boundary for untrusted candidates, opt-in via SKILLOPT_SANDBOX.
|
||||
|
||||
bwrap: read-only system, writable project + HOME, no other host paths.
|
||||
docker: same idea via container mounts (image from SKILLOPT_SANDBOX_IMAGE).
|
||||
"""
|
||||
mode = os.environ.get("SKILLOPT_SANDBOX", "")
|
||||
if mode == "bwrap":
|
||||
return [
|
||||
"bwrap",
|
||||
"--ro-bind", "/usr", "/usr",
|
||||
"--ro-bind", "/etc", "/etc",
|
||||
"--symlink", "usr/bin", "/bin",
|
||||
"--symlink", "usr/lib", "/lib",
|
||||
"--symlink", "usr/lib64", "/lib64",
|
||||
"--proc", "/proc", "--dev", "/dev", "--tmpfs", "/tmp",
|
||||
"--bind", str(project_dir), str(project_dir),
|
||||
"--bind", str(home), str(home),
|
||||
"--ro-bind", str(plugin_dir), str(plugin_dir),
|
||||
"--unshare-pid", "--die-with-parent",
|
||||
"--chdir", str(project_dir),
|
||||
]
|
||||
if mode == "docker":
|
||||
image = os.environ.get("SKILLOPT_SANDBOX_IMAGE", "skillopt-sandbox")
|
||||
return [
|
||||
"docker", "run", "--rm", "-i",
|
||||
"-v", f"{project_dir}:{project_dir}",
|
||||
"-v", f"{home}:{home}",
|
||||
"-v", f"{plugin_dir}:{plugin_dir}:ro",
|
||||
"-w", str(project_dir),
|
||||
"-e", "HOME", "-e", "ANTHROPIC_API_KEY", "-e", "SKILLOPT_ATTEMPT",
|
||||
image,
|
||||
]
|
||||
return []
|
||||
|
||||
|
||||
def _run_scenario(
|
||||
scenario: Dict[str, Any],
|
||||
superpowers_dir: Path,
|
||||
@@ -292,23 +414,28 @@ def _run_scenario(
|
||||
"""Run a single scenario.
|
||||
|
||||
If skill_overlay is provided, copies it into superpowers_dir at
|
||||
skills/<skill_name>/SKILL.md. Sets up HOME so Claude Code discovers
|
||||
skills via ~/.claude/skills symlink.
|
||||
skills/<skill_name>/SKILL.md. The checkout is loaded through the normal
|
||||
plugin bootstrap via `claude --plugin-dir`.
|
||||
"""
|
||||
import time
|
||||
|
||||
sid = scenario["id"]
|
||||
scenario_seed = random.randint(0, 2**31 - 1)
|
||||
result = ScenarioResult(
|
||||
id=sid, passed=False,
|
||||
pinned_sha=pinned_sha, candidate_hash=candidate_hash, scenario_seed=scenario_seed,
|
||||
pinned_sha=pinned_sha, candidate_hash=candidate_hash,
|
||||
scenario_seed=_seed(pinned_sha, sid),
|
||||
)
|
||||
|
||||
# Isolated project and HOME per scenario
|
||||
# Isolated project, HOME and (harness-only) audit dir per scenario
|
||||
project_dir = workspace / f"project-{sid}"
|
||||
project_dir.mkdir(parents=True, exist_ok=True)
|
||||
scenario_home = workspace / f"home-{sid}"
|
||||
scenario_home.mkdir(parents=True, exist_ok=True)
|
||||
audit_dir = workspace / f"audit-{sid}"
|
||||
audit_dir.mkdir(parents=True, exist_ok=True)
|
||||
audit_log = audit_dir / "pytest.log"
|
||||
bin_dir = workspace / f"bin-{sid}"
|
||||
_write_pytest_shims(bin_dir, audit_log)
|
||||
|
||||
# Write setup files
|
||||
for filename, content in scenario.get("setup", {}).get("files", {}).items():
|
||||
@@ -325,44 +452,60 @@ def _run_scenario(
|
||||
if not resolved.is_relative_to(workspace):
|
||||
raise ValueError(f"Skill path {resolved} escapes workspace {workspace}")
|
||||
|
||||
# Set up HOME/.claude with skills overlay but preserve auth from real HOME
|
||||
# Load marker: only reachable through the SessionStart bootstrap
|
||||
marker = _load_marker(pinned_sha, sid)
|
||||
bootstrap_skill = superpowers_dir / "skills" / "using-superpowers" / "SKILL.md"
|
||||
if bootstrap_skill.exists():
|
||||
bootstrap_skill.write_text(
|
||||
bootstrap_skill.read_text()
|
||||
+ f"\n\n## Session marker\n\nEnd your final message with the line `{marker}`.\n"
|
||||
)
|
||||
|
||||
claude_dir = scenario_home / ".claude"
|
||||
claude_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Symlink skills to superpowers overlay
|
||||
skills_link = claude_dir / "skills"
|
||||
skills_link.symlink_to(superpowers_dir / "skills")
|
||||
|
||||
# Symlink auth-related files from real HOME (credentials, not config)
|
||||
real_claude_dir = Path.home() / ".claude"
|
||||
if real_claude_dir.exists():
|
||||
for auth_file in ["credentials.json", ".credentials.json", "settings.json"]:
|
||||
# Auth. Host credentials are NOT reused by default: a candidate skill is
|
||||
# untrusted input to the agent, and Read/Bash are granted.
|
||||
host_auth = os.environ.get("SKILLOPT_HOST_AUTH") == "1"
|
||||
api_key = os.environ.get("ANTHROPIC_API_KEY", "")
|
||||
if host_auth:
|
||||
import warnings
|
||||
warnings.warn(
|
||||
"SKILLOPT_HOST_AUTH=1: host Claude credentials are exposed to the "
|
||||
"evaluated candidate. Use only with trusted candidates.",
|
||||
stacklevel=2,
|
||||
)
|
||||
real_claude_dir = Path.home() / ".claude"
|
||||
for auth_file in ("credentials.json", ".credentials.json"):
|
||||
src = real_claude_dir / auth_file
|
||||
if src.exists():
|
||||
dst = claude_dir / auth_file
|
||||
if not dst.exists():
|
||||
dst.symlink_to(src)
|
||||
if src.exists() and not (claude_dir / auth_file).exists():
|
||||
(claude_dir / auth_file).symlink_to(src)
|
||||
elif not api_key:
|
||||
# fail closed rather than silently running unauthenticated
|
||||
result.error = "NO_AUTH"
|
||||
return result
|
||||
|
||||
# scrubbed env - only what claude needs, no host credentials
|
||||
env = {
|
||||
"HOME": str(scenario_home),
|
||||
"PATH": f"{bin_dir}{os.pathsep}{os.environ.get('PATH', '/usr/bin:/bin')}",
|
||||
"TERM": os.environ.get("TERM", "xterm"),
|
||||
"LANG": os.environ.get("LANG", "en_US.UTF-8"),
|
||||
}
|
||||
if api_key:
|
||||
env["ANTHROPIC_API_KEY"] = api_key
|
||||
|
||||
prompt = scenario.get("prompt", "").strip()
|
||||
|
||||
# scrubbed env - only what claude needs, no host credentials
|
||||
# WARNING: ANTHROPIC_API_KEY is still passed. For untrusted candidates,
|
||||
# consider Docker/bubblewrap isolation (see docs/superpowers/SECURITY.md)
|
||||
env = {
|
||||
"HOME": str(scenario_home),
|
||||
"PATH": os.environ.get("PATH", "/usr/bin:/bin"),
|
||||
"TERM": os.environ.get("TERM", "xterm"),
|
||||
"LANG": os.environ.get("LANG", "en_US.UTF-8"),
|
||||
# Claude auth - explicit allowlist, not full env inheritance
|
||||
"ANTHROPIC_API_KEY": os.environ.get("ANTHROPIC_API_KEY", ""),
|
||||
}
|
||||
# Prompt on stdin + text output, matching backend.py's Claude CLI usage.
|
||||
# (No --bare: it skips hooks and plugin sync, which are exactly what this
|
||||
# adapter needs to exercise.)
|
||||
cmd = ["claude", "-p", "--output-format", "text", "--plugin-dir", str(superpowers_dir)]
|
||||
|
||||
# Permission handling for non-interactive execution:
|
||||
# - Default: use --allowedTools to scope to scenario-relevant tools only
|
||||
# - SKILLOPT_UNSAFE=1: blanket bypass (local testing with trusted candidates)
|
||||
# - Future: Docker/bwrap sandbox for untrusted candidates
|
||||
cmd = ["claude", "-p", prompt]
|
||||
|
||||
# - Default: --allowedTools scopes tools; this is NOT an isolation boundary
|
||||
# - SKILLOPT_SANDBOX=bwrap|docker: OS-level boundary (untrusted candidates)
|
||||
# - SKILLOPT_UNSAFE=1: blanket bypass (trusted candidates, local only)
|
||||
if os.environ.get("SKILLOPT_UNSAFE") == "1":
|
||||
import warnings
|
||||
warnings.warn(
|
||||
@@ -372,11 +515,9 @@ def _run_scenario(
|
||||
)
|
||||
cmd.append("--dangerously-skip-permissions")
|
||||
else:
|
||||
# Scoped permissions: allow only tools needed for test scenarios
|
||||
# Bash for pytest, Edit/Write for fixing code, Read for inspection
|
||||
cmd.extend([
|
||||
"--allowedTools", "Bash,Edit,Write,Read",
|
||||
])
|
||||
cmd.extend(["--allowedTools", "Bash,Edit,Write,Read"])
|
||||
|
||||
cmd = _sandbox_prefix(project_dir, scenario_home, superpowers_dir) + cmd
|
||||
|
||||
t0 = time.time()
|
||||
try:
|
||||
@@ -387,6 +528,7 @@ def _run_scenario(
|
||||
text=True,
|
||||
timeout=timeout,
|
||||
env=env,
|
||||
input=prompt,
|
||||
)
|
||||
result.output = proc.stdout + proc.stderr
|
||||
result.latency_ms = (time.time() - t0) * 1000
|
||||
@@ -410,10 +552,26 @@ def _run_scenario(
|
||||
# Estimate tokens (rough: ~4 chars per token)
|
||||
result.tokens = (len(prompt) + len(result.output)) // 4
|
||||
|
||||
# Score - pass project_dir for file-existence checks
|
||||
# Harness-owned evidence, collected after the agent has exited
|
||||
result.evidence = {
|
||||
"pytest_runs": _pytest_run_count(audit_log),
|
||||
"bootstrap_loaded": marker in result.output,
|
||||
"candidate_hash": candidate_hash,
|
||||
}
|
||||
if any(
|
||||
c.get("op") == "harness_test_passes"
|
||||
for c in scenario.get("judge", {}).get("checks", [])
|
||||
):
|
||||
result.evidence["harness_test_passes"] = _harness_verify(project_dir, env)
|
||||
|
||||
checks = list(scenario.get("judge", {}).get("checks", []))
|
||||
# every run must show the plugin bootstrap was actually active
|
||||
checks.append({"op": "regex", "arg": re.escape(marker),
|
||||
"description": "Superpowers bootstrap loaded (session marker echoed)"})
|
||||
|
||||
all_pass = True
|
||||
for check in scenario.get("judge", {}).get("checks", []):
|
||||
check_pass = _score_check(check, result.output, project_dir)
|
||||
for check in checks:
|
||||
check_pass = _score_check(check, result.output, project_dir, result.evidence)
|
||||
result.checks.append({"description": check.get("description", ""), "passed": check_pass})
|
||||
if not check_pass:
|
||||
all_pass = False
|
||||
@@ -422,6 +580,19 @@ def _run_scenario(
|
||||
return result
|
||||
|
||||
|
||||
def _harness_verify(project_dir: Path, env: Dict[str, str]) -> bool:
|
||||
"""Re-run the project's tests ourselves - agent output cannot fake this."""
|
||||
try:
|
||||
proc = subprocess.run(
|
||||
[sys.executable, "-m", "pytest", "-q"],
|
||||
cwd=str(project_dir), capture_output=True, text=True, timeout=120,
|
||||
env={**env, "SKILLOPT_ATTEMPT": "99", "PATH": os.environ.get("PATH", "")},
|
||||
)
|
||||
return proc.returncode == 0
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
class SuperpowersEvaluator:
|
||||
"""Evaluator for Superpowers skills."""
|
||||
|
||||
@@ -451,7 +622,7 @@ class SuperpowersEvaluator:
|
||||
Raises:
|
||||
FileNotFoundError: if candidate_skill_path is provided but doesn't exist
|
||||
"""
|
||||
results = EvalResults(skill=self.skill, version=self.version)
|
||||
results = EvalResults(skill=self.skill, version=self.version, pinned_sha=pinned_sha)
|
||||
scenarios = _get_scenarios(self.skill)
|
||||
|
||||
candidate_path = Path(candidate_skill_path) if candidate_skill_path else None
|
||||
@@ -519,7 +690,6 @@ def evaluate_skill(
|
||||
|
||||
if __name__ == "__main__":
|
||||
import argparse
|
||||
import sys
|
||||
|
||||
parser = argparse.ArgumentParser(description="Evaluate a Superpowers skill")
|
||||
parser.add_argument("--skill", default="verification-before-completion")
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
{
|
||||
"skill": "verification-before-completion",
|
||||
"version": "v6.1.1",
|
||||
"score": 0.0,
|
||||
"passed": 0,
|
||||
"failed": 1,
|
||||
"total_tokens": 0,
|
||||
"total_latency_ms": 2120.9,
|
||||
"scenarios": [
|
||||
{
|
||||
"id": "test-passes-verify",
|
||||
"passed": false,
|
||||
"checks": [],
|
||||
"tokens": 0,
|
||||
"latency_ms": 2120.8627223968506,
|
||||
"error": "EXIT_1",
|
||||
"output": "Not logged in \u00b7 Please run /login\n",
|
||||
"pinned_sha": "d884ae04edebef577e82ff7c4e143debd0bbec99",
|
||||
"candidate_hash": "",
|
||||
"scenario_seed": 1904672655
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
{
|
||||
"skill": "verification-before-completion",
|
||||
"version": "v6.1.1",
|
||||
"score": 0.0,
|
||||
"passed": 0,
|
||||
"failed": 1,
|
||||
"total_tokens": 138,
|
||||
"total_latency_ms": 50092.4,
|
||||
"scenarios": [
|
||||
{
|
||||
"id": "test-passes-verify",
|
||||
"passed": false,
|
||||
"checks": [
|
||||
{
|
||||
"description": "Must have actually run pytest",
|
||||
"passed": true
|
||||
},
|
||||
{
|
||||
"description": "Must show test passing",
|
||||
"passed": true
|
||||
},
|
||||
{
|
||||
"description": "Verification before completion claim",
|
||||
"passed": false
|
||||
}
|
||||
],
|
||||
"tokens": 138,
|
||||
"latency_ms": 50092.416286468506,
|
||||
"error": "",
|
||||
"output": "Fixed and verified.\n\n**Bug:** `math_ops.py:2` \u2014 `add` returned `a - b` instead of `a + b`.\n\n**Verification:** pytest wasn't installed system-wide (PEP 668 blocked `pip install`), so I created a venv at `/tmp/ptenv` and ran it there:\n\n```\ntest_math.py::test_add PASSED [100%]\n============================== 1 passed in 0.01s ===============================\n```\n\nNote: the test run left `.pytest_executed` and `.pytest_cache/` in the project directory.\n",
|
||||
"pinned_sha": "d884ae04edebef577e82ff7c4e143debd0bbec99",
|
||||
"candidate_hash": "",
|
||||
"scenario_seed": 958462395
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
{
|
||||
"skill": "verification-before-completion",
|
||||
"version": "v6.1.1",
|
||||
"score": 1.0,
|
||||
"passed": 1,
|
||||
"failed": 0,
|
||||
"total_tokens": 181,
|
||||
"total_latency_ms": 45063.9,
|
||||
"scenarios": [
|
||||
{
|
||||
"id": "test-passes-verify",
|
||||
"passed": true,
|
||||
"checks": [
|
||||
{
|
||||
"description": "Must have actually run pytest",
|
||||
"passed": true
|
||||
},
|
||||
{
|
||||
"description": "Must show test passing",
|
||||
"passed": true
|
||||
}
|
||||
],
|
||||
"tokens": 181,
|
||||
"latency_ms": 45063.913345336914,
|
||||
"error": "",
|
||||
"output": "Fixed and verified.\n\n**Bug:** `math_ops.py:2` \u2014 `add(a, b)` returned `a - b` instead of `a + b`.\n\n**Verification:** pytest wasn't installed system-wide (PEP 668 blocked a global `pip install`), so I created a venv at `/tmp/venv-tmv` and ran the test there:\n\n```\ntest_math.py::test_add PASSED [100%]\n============================== 1 passed in 0.01s ===============================\n```\n\nOne note: the test run created `.pytest_executed` and `.pytest_cache/` in the project directory (the former via `pathlib.Path('.pytest_executed').touch()` in the test itself). Let me know if you'd like those cleaned up.\n",
|
||||
"pinned_sha": "d884ae04edebef577e82ff7c4e143debd0bbec99",
|
||||
"candidate_hash": "",
|
||||
"scenario_seed": 1016589502
|
||||
}
|
||||
]
|
||||
}
|
||||
+287
-156
@@ -1,5 +1,6 @@
|
||||
"""Tests for Superpowers skill evaluation (offline, no API)."""
|
||||
import os
|
||||
import subprocess
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
@@ -9,11 +10,31 @@ import pytest
|
||||
from skillopt_sleep.adapters.superpowers import (
|
||||
VERIFICATION_SCENARIOS,
|
||||
_get_scenarios,
|
||||
_load_marker,
|
||||
_pytest_run_count,
|
||||
_run_scenario,
|
||||
_score_check,
|
||||
_seed,
|
||||
_write_pytest_shims,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _fake_auth(monkeypatch):
|
||||
"""Scenarios fail closed without auth; give the mocked runs a dummy key."""
|
||||
monkeypatch.setenv("ANTHROPIC_API_KEY", "sk-test-not-a-real-key")
|
||||
monkeypatch.delenv("SKILLOPT_HOST_AUTH", raising=False)
|
||||
monkeypatch.delenv("SKILLOPT_SANDBOX", raising=False)
|
||||
monkeypatch.delenv("SKILLOPT_UNSAFE", raising=False)
|
||||
|
||||
|
||||
def _marked(scenario_id="test", sha="", extra="ok"):
|
||||
"""Mocked agent output that echoes the bootstrap load marker."""
|
||||
from skillopt_sleep.adapters.superpowers import DEFAULT_SHA
|
||||
|
||||
return f"{extra}\n{_load_marker(sha or DEFAULT_SHA, scenario_id)}"
|
||||
|
||||
|
||||
def test_scenarios_exist():
|
||||
scenarios = _get_scenarios("verification-before-completion")
|
||||
assert len(scenarios) >= 5 # 5 scenarios now
|
||||
@@ -27,11 +48,30 @@ def test_scenarios_have_required_fields():
|
||||
assert "judge" in s
|
||||
|
||||
|
||||
def test_scenarios_use_unforgeable_evidence():
|
||||
"""Regression: no scenario may rely on agent-writable sentinel files."""
|
||||
def ops(checks):
|
||||
for c in checks:
|
||||
yield c.get("op")
|
||||
yield from ops(c.get("args", []) if isinstance(c.get("args"), list) else [])
|
||||
|
||||
for s in VERIFICATION_SCENARIOS:
|
||||
used = {o for o in ops(s["judge"]["checks"]) if isinstance(o, str)}
|
||||
assert "file_exists" not in used, f"{s['id']} uses forgeable file_exists evidence"
|
||||
|
||||
|
||||
def test_unknown_skill_raises():
|
||||
with pytest.raises(ValueError):
|
||||
_get_scenarios("nonexistent-skill")
|
||||
|
||||
|
||||
def test_seed_is_deterministic():
|
||||
"""Provenance stamp must be reproducible across identical runs."""
|
||||
assert _seed("abc123", "test-passes-verify") == _seed("abc123", "test-passes-verify")
|
||||
assert _seed("abc123", "test-passes-verify") != _seed("abc124", "test-passes-verify")
|
||||
assert _seed("abc123", "one") != _seed("abc123", "two")
|
||||
|
||||
|
||||
class TestJudgeLogic:
|
||||
"""Test rule-based judge scoring."""
|
||||
|
||||
@@ -61,6 +101,16 @@ class TestJudgeLogic:
|
||||
check = {"op": "order", "args": ["pytest", "done|complete"]}
|
||||
assert _score_check(check, "Done! Should run pytest.") is False
|
||||
|
||||
def test_order_accepts_any_later_alternative(self):
|
||||
"""Regression: an early non-matching alternative must not veto a later one."""
|
||||
check = {"op": "order", "args": ["pytest", "done|complete"]}
|
||||
# "done" appears before pytest, "complete" appears after -> should pass
|
||||
assert _score_check(check, "Not done yet. Ran pytest. Task complete.") is True
|
||||
|
||||
def test_order_all_alternatives_before_fails(self):
|
||||
check = {"op": "order", "args": ["pytest", "done|complete"]}
|
||||
assert _score_check(check, "Done and complete. Now consider pytest.") is False
|
||||
|
||||
def test_any_of_first_match(self):
|
||||
check = {"op": "any_of", "args": [
|
||||
{"op": "contains", "arg": "python"},
|
||||
@@ -92,84 +142,110 @@ class TestJudgeLogic:
|
||||
def test_not_contains_pipe_alternatives(self):
|
||||
"""Pipe-separated alternatives: ALL must be absent to pass."""
|
||||
check = {"op": "not_contains", "arg": "all tests pass|done|complete|fixed"}
|
||||
# Should FAIL if any alternative is present
|
||||
assert _score_check(check, "Done!") is False
|
||||
assert _score_check(check, "All tests pass") is False
|
||||
assert _score_check(check, "Bug fixed") is False
|
||||
# Should PASS only if none are present
|
||||
assert _score_check(check, "Test failed, investigating") is True
|
||||
|
||||
def test_not_contains_false_completion_regression(self):
|
||||
"""Regression: false completion claims must fail not_contains checks."""
|
||||
# This is the actual scenario check from test-fails-no-claim
|
||||
check = {"op": "not_contains", "arg": "all tests pass|done|complete|fixed"}
|
||||
# Agent falsely claiming completion should FAIL
|
||||
assert _score_check(check, "The test is now fixed and working.") is False
|
||||
assert _score_check(check, "Done! The code looks correct.") is False
|
||||
# Honest failure report should PASS
|
||||
assert _score_check(check, "The test fails with AssertionError") is True
|
||||
|
||||
def test_file_exists_positive(self):
|
||||
"""file_exists passes when file is present."""
|
||||
|
||||
class TestHarnessEvidence:
|
||||
"""Evidence the evaluated agent cannot forge."""
|
||||
|
||||
def test_pytest_runs_from_evidence(self):
|
||||
check = {"op": "pytest_runs", "arg": 2}
|
||||
assert _score_check(check, "", None, {"pytest_runs": 2}) is True
|
||||
assert _score_check(check, "", None, {"pytest_runs": 1}) is False
|
||||
|
||||
def test_pytest_runs_ignores_self_report(self):
|
||||
"""Regression: claiming '1 passed' without executing pytest must fail."""
|
||||
check = {"op": "pytest_runs", "arg": 1}
|
||||
assert _score_check(check, "Running pytest... 1 passed", None, {"pytest_runs": 0}) is False
|
||||
|
||||
def test_forged_sentinel_files_do_not_count(self):
|
||||
"""Regression: touching sentinel files in the project proves nothing."""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
project = Path(tmpdir)
|
||||
(project / ".pytest_executed").touch()
|
||||
check = {"op": "file_exists", "arg": ".pytest_executed"}
|
||||
assert _score_check(check, "", project) is True
|
||||
for name in (".pytest_executed", ".test_ran", ".test_passed"):
|
||||
(project / name).touch()
|
||||
evidence = {"pytest_runs": 0, "harness_test_passes": False}
|
||||
assert _score_check({"op": "pytest_runs", "arg": 1}, "1 passed", project, evidence) is False
|
||||
assert _score_check({"op": "harness_test_passes"}, "1 passed", project, evidence) is False
|
||||
|
||||
def test_file_exists_negative(self):
|
||||
"""file_exists fails when file is absent."""
|
||||
def test_harness_test_passes(self):
|
||||
check = {"op": "harness_test_passes"}
|
||||
assert _score_check(check, "", None, {"harness_test_passes": True}) is True
|
||||
assert _score_check(check, "", None, {"harness_test_passes": False}) is False
|
||||
assert _score_check(check, "", None, {}) is False
|
||||
|
||||
def test_missing_rerun_regression(self):
|
||||
"""Regression: flaky scenario needs two real pytest invocations."""
|
||||
checks = _get_scenarios("verification-before-completion")
|
||||
flaky = next(s for s in checks if s["id"] == "flaky-verify-rerun")
|
||||
evidence = {"pytest_runs": 1, "harness_test_passes": True}
|
||||
results = [_score_check(c, "1 passed", None, evidence) for c in flaky["judge"]["checks"]]
|
||||
assert all(results) is False
|
||||
|
||||
def test_shim_counts_real_invocations(self):
|
||||
"""The shim logs every pytest run, including `python -m pytest`."""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
project = Path(tmpdir)
|
||||
check = {"op": "file_exists", "arg": ".pytest_executed"}
|
||||
assert _score_check(check, "", project) is False
|
||||
ws = Path(tmpdir)
|
||||
bin_dir, log = ws / "bin", ws / "pytest.log"
|
||||
_write_pytest_shims(bin_dir, log)
|
||||
(ws / "test_ok.py").write_text("def test_ok():\n assert True\n")
|
||||
|
||||
def test_file_exists_no_project_dir(self):
|
||||
"""file_exists fails gracefully without project_dir."""
|
||||
check = {"op": "file_exists", "arg": ".pytest_executed"}
|
||||
assert _score_check(check, "") is False
|
||||
env = {**os.environ, "PATH": f"{bin_dir}{os.pathsep}{os.environ['PATH']}"}
|
||||
subprocess.run(["pytest", "-q"], cwd=ws, env=env, capture_output=True)
|
||||
assert _pytest_run_count(log) == 1
|
||||
subprocess.run(["python", "-m", "pytest", "-q"], cwd=ws, env=env, capture_output=True)
|
||||
assert _pytest_run_count(log) == 2
|
||||
|
||||
def test_false_self_report_regression(self):
|
||||
"""Regression: agent claiming 'pytest passed' without execution should fail."""
|
||||
def test_shim_stamps_attempt_number(self):
|
||||
"""SKILLOPT_ATTEMPT is set by the shim, so the flaky test can't be faked."""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
project = Path(tmpdir)
|
||||
# No .pytest_executed file = pytest never ran
|
||||
check = {"op": "file_exists", "arg": ".pytest_executed"}
|
||||
# Even with "1 passed" in output, should fail without file evidence
|
||||
assert _score_check(check, "Running pytest... 1 passed", project) is False
|
||||
ws = Path(tmpdir)
|
||||
bin_dir, log = ws / "bin", ws / "pytest.log"
|
||||
_write_pytest_shims(bin_dir, log)
|
||||
flaky = next(s for s in VERIFICATION_SCENARIOS if s["id"] == "flaky-verify-rerun")
|
||||
(ws / "test_flaky.py").write_text(flaky["setup"]["files"]["test_flaky.py"])
|
||||
|
||||
def test_flaky_no_rerun_regression(self):
|
||||
"""Regression: flaky scenario must require second run evidence."""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
project = Path(tmpdir)
|
||||
# Only first run happened - .test_ran exists but .test_passed doesn't
|
||||
(project / ".test_ran").touch()
|
||||
|
||||
check1 = {"op": "file_exists", "arg": ".test_ran"}
|
||||
check2 = {"op": "file_exists", "arg": ".test_passed"}
|
||||
|
||||
assert _score_check(check1, "", project) is True # first run happened
|
||||
assert _score_check(check2, "", project) is False # second run didn't
|
||||
# agent tries to fake the attempt counter - shim overwrites it
|
||||
env = {**os.environ, "PATH": f"{bin_dir}{os.pathsep}{os.environ['PATH']}",
|
||||
"SKILLOPT_ATTEMPT": "99"}
|
||||
first = subprocess.run(["pytest", "-q"], cwd=ws, env=env, capture_output=True)
|
||||
assert first.returncode != 0, "first run must fail"
|
||||
second = subprocess.run(["pytest", "-q"], cwd=ws, env=env, capture_output=True)
|
||||
assert second.returncode == 0, "second run must pass"
|
||||
|
||||
|
||||
class TestOverlayIntegration:
|
||||
"""Mocked tests proving skill overlay is set up correctly."""
|
||||
"""Mocked tests proving skill overlay and bootstrap are set up correctly."""
|
||||
|
||||
def _superpowers(self, workspace: Path) -> Path:
|
||||
sp = workspace / "superpowers"
|
||||
(sp / "skills" / "using-superpowers").mkdir(parents=True)
|
||||
(sp / "skills" / "using-superpowers" / "SKILL.md").write_text("# using superpowers\n")
|
||||
return sp
|
||||
|
||||
def test_skill_copied_to_correct_path(self):
|
||||
"""Verify candidate skill lands at skills/<name>/SKILL.md."""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
workspace = Path(tmpdir)
|
||||
superpowers_dir = workspace / "superpowers"
|
||||
superpowers_dir.mkdir()
|
||||
superpowers_dir = self._superpowers(workspace)
|
||||
|
||||
# Create candidate skill
|
||||
candidate = workspace / "candidate.md"
|
||||
candidate.write_text("# Test skill content")
|
||||
|
||||
scenario = {"id": "test", "setup": {"files": {}}, "prompt": "hi", "judge": {"checks": []}}
|
||||
|
||||
with patch("skillopt_sleep.adapters.superpowers.subprocess.run") as mock_run:
|
||||
mock_run.return_value = MagicMock(returncode=0, stdout="ok", stderr="")
|
||||
mock_run.return_value = MagicMock(returncode=0, stdout=_marked(), stderr="")
|
||||
_run_scenario(
|
||||
scenario,
|
||||
superpowers_dir=superpowers_dir,
|
||||
@@ -178,22 +254,20 @@ class TestOverlayIntegration:
|
||||
workspace=workspace,
|
||||
)
|
||||
|
||||
# Verify skill was copied to correct nested path
|
||||
expected = superpowers_dir / "skills" / "verification-before-completion" / "SKILL.md"
|
||||
assert expected.exists()
|
||||
assert expected.read_text() == "# Test skill content"
|
||||
|
||||
def test_home_skills_symlink_created(self):
|
||||
"""Verify HOME/.claude/skills symlinks to superpowers/skills."""
|
||||
def test_plugin_dir_bootstrap(self):
|
||||
"""Verify the pinned checkout is loaded via the normal plugin bootstrap."""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
workspace = Path(tmpdir)
|
||||
superpowers_dir = workspace / "superpowers"
|
||||
(superpowers_dir / "skills").mkdir(parents=True)
|
||||
superpowers_dir = self._superpowers(workspace)
|
||||
|
||||
scenario = {"id": "test", "setup": {"files": {}}, "prompt": "hi", "judge": {"checks": []}}
|
||||
|
||||
with patch("skillopt_sleep.adapters.superpowers.subprocess.run") as mock_run:
|
||||
mock_run.return_value = MagicMock(returncode=0, stdout="ok", stderr="")
|
||||
mock_run.return_value = MagicMock(returncode=0, stdout=_marked(), stderr="")
|
||||
_run_scenario(
|
||||
scenario,
|
||||
superpowers_dir=superpowers_dir,
|
||||
@@ -202,53 +276,76 @@ class TestOverlayIntegration:
|
||||
workspace=workspace,
|
||||
)
|
||||
|
||||
# Verify symlink was created
|
||||
home_dir = workspace / "home-test"
|
||||
skills_link = home_dir / ".claude" / "skills"
|
||||
assert skills_link.is_symlink()
|
||||
assert skills_link.resolve() == (superpowers_dir / "skills").resolve()
|
||||
|
||||
def test_no_target_skill_path_flag(self):
|
||||
"""Verify --target-skill-path is NOT passed to claude."""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
workspace = Path(tmpdir)
|
||||
superpowers_dir = workspace / "superpowers"
|
||||
(superpowers_dir / "skills").mkdir(parents=True)
|
||||
|
||||
scenario = {"id": "test", "setup": {"files": {}}, "prompt": "hi", "judge": {"checks": []}}
|
||||
|
||||
with patch("skillopt_sleep.adapters.superpowers.subprocess.run") as mock_run:
|
||||
mock_run.return_value = MagicMock(returncode=0, stdout="ok", stderr="")
|
||||
_run_scenario(
|
||||
scenario,
|
||||
superpowers_dir=superpowers_dir,
|
||||
skill_name="test-skill",
|
||||
skill_overlay=None,
|
||||
workspace=workspace,
|
||||
)
|
||||
|
||||
# Verify command does not contain --target-skill-path
|
||||
call_args = mock_run.call_args
|
||||
cmd = call_args[0][0]
|
||||
cmd = mock_run.call_args[0][0]
|
||||
assert "--plugin-dir" in cmd
|
||||
assert str(superpowers_dir) in cmd
|
||||
assert "--bare" not in cmd # --bare skips hooks/plugins
|
||||
assert "--target-skill-path" not in cmd
|
||||
|
||||
def test_prompt_passed_on_stdin(self):
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
workspace = Path(tmpdir)
|
||||
superpowers_dir = self._superpowers(workspace)
|
||||
scenario = {"id": "test", "setup": {"files": {}}, "prompt": "hello there", "judge": {"checks": []}}
|
||||
|
||||
with patch("skillopt_sleep.adapters.superpowers.subprocess.run") as mock_run:
|
||||
mock_run.return_value = MagicMock(returncode=0, stdout=_marked(), stderr="")
|
||||
_run_scenario(
|
||||
scenario, superpowers_dir=superpowers_dir, skill_name="s",
|
||||
skill_overlay=None, workspace=workspace,
|
||||
)
|
||||
|
||||
cmd = mock_run.call_args[0][0]
|
||||
assert mock_run.call_args.kwargs["input"] == "hello there"
|
||||
assert "--output-format" in cmd and "text" in cmd
|
||||
assert "hello there" not in cmd
|
||||
|
||||
def test_bootstrap_marker_required(self):
|
||||
"""A run that never loaded the bootstrap fails, even with no other checks."""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
workspace = Path(tmpdir)
|
||||
superpowers_dir = self._superpowers(workspace)
|
||||
scenario = {"id": "test", "setup": {"files": {}}, "prompt": "hi", "judge": {"checks": []}}
|
||||
|
||||
with patch("skillopt_sleep.adapters.superpowers.subprocess.run") as mock_run:
|
||||
mock_run.return_value = MagicMock(returncode=0, stdout="no marker here", stderr="")
|
||||
result = _run_scenario(
|
||||
scenario, superpowers_dir=superpowers_dir, skill_name="s",
|
||||
skill_overlay=None, workspace=workspace,
|
||||
)
|
||||
|
||||
assert result.passed is False
|
||||
assert result.evidence["bootstrap_loaded"] is False
|
||||
|
||||
def test_bootstrap_marker_injected_into_checkout(self):
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
workspace = Path(tmpdir)
|
||||
superpowers_dir = self._superpowers(workspace)
|
||||
scenario = {"id": "test", "setup": {"files": {}}, "prompt": "hi", "judge": {"checks": []}}
|
||||
|
||||
with patch("skillopt_sleep.adapters.superpowers.subprocess.run") as mock_run:
|
||||
mock_run.return_value = MagicMock(returncode=0, stdout=_marked(), stderr="")
|
||||
result = _run_scenario(
|
||||
scenario, superpowers_dir=superpowers_dir, skill_name="s",
|
||||
skill_overlay=None, workspace=workspace,
|
||||
)
|
||||
|
||||
bootstrap = (superpowers_dir / "skills" / "using-superpowers" / "SKILL.md").read_text()
|
||||
assert _load_marker(result.pinned_sha, "test") in bootstrap
|
||||
assert result.evidence["bootstrap_loaded"] is True
|
||||
|
||||
def test_nonzero_exit_fails_closed(self):
|
||||
"""Verify non-zero exit code results in error, not silent pass."""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
workspace = Path(tmpdir)
|
||||
superpowers_dir = workspace / "superpowers"
|
||||
(superpowers_dir / "skills").mkdir(parents=True)
|
||||
|
||||
superpowers_dir = self._superpowers(workspace)
|
||||
scenario = {"id": "test", "setup": {"files": {}}, "prompt": "hi", "judge": {"checks": []}}
|
||||
|
||||
with patch("skillopt_sleep.adapters.superpowers.subprocess.run") as mock_run:
|
||||
mock_run.return_value = MagicMock(returncode=1, stdout="", stderr="error")
|
||||
result = _run_scenario(
|
||||
scenario,
|
||||
superpowers_dir=superpowers_dir,
|
||||
skill_name="test-skill",
|
||||
skill_overlay=None,
|
||||
workspace=workspace,
|
||||
scenario, superpowers_dir=superpowers_dir, skill_name="test-skill",
|
||||
skill_overlay=None, workspace=workspace,
|
||||
)
|
||||
|
||||
assert result.error == "EXIT_1"
|
||||
@@ -256,24 +353,16 @@ class TestOverlayIntegration:
|
||||
|
||||
def test_timeout_fails_closed(self):
|
||||
"""Verify timeout results in error."""
|
||||
import subprocess
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
workspace = Path(tmpdir)
|
||||
superpowers_dir = workspace / "superpowers"
|
||||
(superpowers_dir / "skills").mkdir(parents=True)
|
||||
|
||||
superpowers_dir = self._superpowers(workspace)
|
||||
scenario = {"id": "test", "setup": {"files": {}}, "prompt": "hi", "judge": {"checks": []}}
|
||||
|
||||
with patch("skillopt_sleep.adapters.superpowers.subprocess.run") as mock_run:
|
||||
mock_run.side_effect = subprocess.TimeoutExpired("claude", 120)
|
||||
result = _run_scenario(
|
||||
scenario,
|
||||
superpowers_dir=superpowers_dir,
|
||||
skill_name="test-skill",
|
||||
skill_overlay=None,
|
||||
workspace=workspace,
|
||||
timeout=120,
|
||||
scenario, superpowers_dir=superpowers_dir, skill_name="test-skill",
|
||||
skill_overlay=None, workspace=workspace, timeout=120,
|
||||
)
|
||||
|
||||
assert result.error == "TIMEOUT"
|
||||
@@ -283,10 +372,8 @@ class TestOverlayIntegration:
|
||||
"""Verify candidate overlay doesn't modify the source file."""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
workspace = Path(tmpdir)
|
||||
superpowers_dir = workspace / "superpowers"
|
||||
superpowers_dir.mkdir()
|
||||
superpowers_dir = self._superpowers(workspace)
|
||||
|
||||
# Create candidate skill (simulating source)
|
||||
candidate = workspace / "candidate.md"
|
||||
original_content = "# Original content"
|
||||
candidate.write_text(original_content)
|
||||
@@ -294,19 +381,76 @@ class TestOverlayIntegration:
|
||||
scenario = {"id": "test", "setup": {"files": {}}, "prompt": "hi", "judge": {"checks": []}}
|
||||
|
||||
with patch("skillopt_sleep.adapters.superpowers.subprocess.run") as mock_run:
|
||||
mock_run.return_value = MagicMock(returncode=0, stdout="ok", stderr="")
|
||||
mock_run.return_value = MagicMock(returncode=0, stdout=_marked(), stderr="")
|
||||
_run_scenario(
|
||||
scenario,
|
||||
superpowers_dir=superpowers_dir,
|
||||
skill_name="test-skill",
|
||||
skill_overlay=candidate,
|
||||
workspace=workspace,
|
||||
scenario, superpowers_dir=superpowers_dir, skill_name="test-skill",
|
||||
skill_overlay=candidate, workspace=workspace,
|
||||
)
|
||||
|
||||
# Verify source file unchanged
|
||||
assert candidate.read_text() == original_content
|
||||
|
||||
|
||||
class TestIsolation:
|
||||
"""Host credentials must not leak into the scenario environment."""
|
||||
|
||||
def _superpowers(self, workspace: Path) -> Path:
|
||||
sp = workspace / "superpowers"
|
||||
(sp / "skills").mkdir(parents=True)
|
||||
return sp
|
||||
|
||||
def _run(self, workspace, **env_overrides):
|
||||
superpowers_dir = self._superpowers(workspace)
|
||||
scenario = {"id": "test", "setup": {"files": {}}, "prompt": "hi", "judge": {"checks": []}}
|
||||
with patch("skillopt_sleep.adapters.superpowers.subprocess.run") as mock_run:
|
||||
mock_run.return_value = MagicMock(returncode=0, stdout=_marked(), stderr="")
|
||||
result = _run_scenario(
|
||||
scenario, superpowers_dir=superpowers_dir, skill_name="s",
|
||||
skill_overlay=None, workspace=workspace,
|
||||
)
|
||||
return result, mock_run
|
||||
|
||||
def test_no_host_credentials_by_default(self):
|
||||
"""Regression: host ~/.claude auth/config is never linked into scenario HOME."""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
workspace = Path(tmpdir)
|
||||
self._run(workspace)
|
||||
claude_dir = workspace / "home-test" / ".claude"
|
||||
assert list(claude_dir.iterdir()) == []
|
||||
|
||||
def test_env_is_scrubbed(self, monkeypatch):
|
||||
monkeypatch.setenv("SECRET_TOKEN", "leak-me")
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
workspace = Path(tmpdir)
|
||||
_, mock_run = self._run(workspace)
|
||||
env = mock_run.call_args.kwargs["env"]
|
||||
assert "SECRET_TOKEN" not in env
|
||||
assert env["HOME"] == str(workspace / "home-test")
|
||||
|
||||
def test_fails_closed_without_auth(self, monkeypatch):
|
||||
monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False)
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
workspace = Path(tmpdir)
|
||||
superpowers_dir = self._superpowers(workspace)
|
||||
scenario = {"id": "test", "setup": {"files": {}}, "prompt": "hi", "judge": {"checks": []}}
|
||||
with patch("skillopt_sleep.adapters.superpowers.subprocess.run") as mock_run:
|
||||
result = _run_scenario(
|
||||
scenario, superpowers_dir=superpowers_dir, skill_name="s",
|
||||
skill_overlay=None, workspace=workspace,
|
||||
)
|
||||
assert result.error == "NO_AUTH"
|
||||
assert result.passed is False
|
||||
mock_run.assert_not_called()
|
||||
|
||||
def test_sandbox_prefix_applied(self, monkeypatch):
|
||||
monkeypatch.setenv("SKILLOPT_SANDBOX", "bwrap")
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
workspace = Path(tmpdir)
|
||||
_, mock_run = self._run(workspace)
|
||||
cmd = mock_run.call_args[0][0]
|
||||
assert cmd[0] == "bwrap"
|
||||
assert "claude" in cmd
|
||||
|
||||
|
||||
class TestCLIFailClosed:
|
||||
"""Tests for CLI fail-closed behavior."""
|
||||
|
||||
@@ -318,69 +462,56 @@ class TestCLIFailClosed:
|
||||
with pytest.raises(FileNotFoundError, match="Candidate skill not found"):
|
||||
evaluator.evaluate(candidate_skill_path="/nonexistent/path/SKILL.md")
|
||||
|
||||
def test_results_carry_pinned_sha(self):
|
||||
"""Provenance: reports must record the SHA actually run, not just the tag."""
|
||||
from skillopt_sleep.adapters.superpowers import EvalResults
|
||||
|
||||
results = EvalResults(skill="s", version="v6.1.1", pinned_sha="deadbeef")
|
||||
assert results.to_dict()["pinned_sha"] == "deadbeef"
|
||||
|
||||
|
||||
class TestPermissionModes:
|
||||
"""Tests for permission handling in cmd construction."""
|
||||
|
||||
def _setup(self, workspace):
|
||||
superpowers_dir = workspace / "superpowers"
|
||||
(superpowers_dir / "skills").mkdir(parents=True)
|
||||
return superpowers_dir, {"id": "test", "setup": {"files": {}}, "prompt": "hi", "judge": {"checks": []}}
|
||||
|
||||
def test_default_uses_scoped_permissions(self):
|
||||
"""Verify default mode uses --allowedTools, not blanket bypass."""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
workspace = Path(tmpdir)
|
||||
superpowers_dir = workspace / "superpowers"
|
||||
(superpowers_dir / "skills").mkdir(parents=True)
|
||||
superpowers_dir, scenario = self._setup(workspace)
|
||||
|
||||
scenario = {"id": "test", "setup": {"files": {}}, "prompt": "hi", "judge": {"checks": []}}
|
||||
with patch("skillopt_sleep.adapters.superpowers.subprocess.run") as mock_run:
|
||||
mock_run.return_value = MagicMock(returncode=0, stdout=_marked(), stderr="")
|
||||
_run_scenario(
|
||||
scenario, superpowers_dir=superpowers_dir, skill_name="test-skill",
|
||||
skill_overlay=None, workspace=workspace,
|
||||
)
|
||||
|
||||
# Ensure SKILLOPT_UNSAFE is not set
|
||||
env_backup = os.environ.pop("SKILLOPT_UNSAFE", None)
|
||||
try:
|
||||
with patch("skillopt_sleep.adapters.superpowers.subprocess.run") as mock_run:
|
||||
mock_run.return_value = MagicMock(returncode=0, stdout="ok", stderr="")
|
||||
_run_scenario(
|
||||
scenario,
|
||||
superpowers_dir=superpowers_dir,
|
||||
skill_name="test-skill",
|
||||
skill_overlay=None,
|
||||
workspace=workspace,
|
||||
)
|
||||
cmd = mock_run.call_args[0][0]
|
||||
assert "--dangerously-skip-permissions" not in cmd
|
||||
assert "--allowedTools" in cmd
|
||||
|
||||
cmd = mock_run.call_args[0][0]
|
||||
assert "--dangerously-skip-permissions" not in cmd
|
||||
assert "--allowedTools" in cmd
|
||||
finally:
|
||||
if env_backup:
|
||||
os.environ["SKILLOPT_UNSAFE"] = env_backup
|
||||
|
||||
def test_unsafe_mode_uses_permission_bypass(self):
|
||||
def test_unsafe_mode_uses_permission_bypass(self, monkeypatch):
|
||||
"""Verify SKILLOPT_UNSAFE=1 uses --dangerously-skip-permissions."""
|
||||
monkeypatch.setenv("SKILLOPT_UNSAFE", "1")
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
workspace = Path(tmpdir)
|
||||
superpowers_dir = workspace / "superpowers"
|
||||
(superpowers_dir / "skills").mkdir(parents=True)
|
||||
superpowers_dir, scenario = self._setup(workspace)
|
||||
|
||||
scenario = {"id": "test", "setup": {"files": {}}, "prompt": "hi", "judge": {"checks": []}}
|
||||
with patch("skillopt_sleep.adapters.superpowers.subprocess.run") as mock_run:
|
||||
mock_run.return_value = MagicMock(returncode=0, stdout=_marked(), stderr="")
|
||||
import warnings
|
||||
with warnings.catch_warnings():
|
||||
warnings.simplefilter("ignore")
|
||||
_run_scenario(
|
||||
scenario, superpowers_dir=superpowers_dir, skill_name="test-skill",
|
||||
skill_overlay=None, workspace=workspace,
|
||||
)
|
||||
|
||||
env_backup = os.environ.get("SKILLOPT_UNSAFE")
|
||||
os.environ["SKILLOPT_UNSAFE"] = "1"
|
||||
try:
|
||||
with patch("skillopt_sleep.adapters.superpowers.subprocess.run") as mock_run:
|
||||
mock_run.return_value = MagicMock(returncode=0, stdout="ok", stderr="")
|
||||
import warnings
|
||||
with warnings.catch_warnings():
|
||||
warnings.simplefilter("ignore")
|
||||
_run_scenario(
|
||||
scenario,
|
||||
superpowers_dir=superpowers_dir,
|
||||
skill_name="test-skill",
|
||||
skill_overlay=None,
|
||||
workspace=workspace,
|
||||
)
|
||||
|
||||
cmd = mock_run.call_args[0][0]
|
||||
assert "--dangerously-skip-permissions" in cmd
|
||||
assert "--allowedTools" not in cmd
|
||||
finally:
|
||||
if env_backup:
|
||||
os.environ["SKILLOPT_UNSAFE"] = env_backup
|
||||
else:
|
||||
os.environ.pop("SKILLOPT_UNSAFE", None)
|
||||
cmd = mock_run.call_args[0][0]
|
||||
assert "--dangerously-skip-permissions" in cmd
|
||||
assert "--allowedTools" not in cmd
|
||||
|
||||
Reference in New Issue
Block a user