Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions docs/sleep/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -201,6 +201,24 @@ An existing file that cannot be decoded, parsed, or read stops the command
with a configuration error and exit code 2; it does not discard your settings
and run with default budgets. Explicit CLI flags still override loaded values.

#### Claude replay isolation

The `claude` replay backend uses `--bare` when `ANTHROPIC_API_KEY` is set.
Without an API key, including subscription/OAuth login, it uses `--safe-mode`
only when the installed CLI advertises that option in a successful `--help`
probe. The capability result is cached for that backend instance. A missing,
failed or timed-out probe refuses replay; it never falls back to an unisolated
call. Upgrade Claude Code, configure an API key or choose another backend if
neither mode is available. SkillOpt does not inspect the user's Claude
configuration or credentials to select a mode.

Safe mode excludes user-level `CLAUDE.md`, skills, plugins, hooks and MCP
customizations while preserving normal authentication and model selection.
**Admin-managed (policy) settings still apply**; this is not an OS sandbox or a
way to bypass organizational policy. The modes actually selected during each
night appear in `diagnostics.json` (`claude_isolation_modes`) and report notes,
including separate optimizer/target roles when configured.

### Harvest filters (environment)

Harvest keeps what you typed and drops what a machine wrote. Four environment
Expand Down
45 changes: 39 additions & 6 deletions skillopt_sleep/backend.py
Original file line number Diff line number Diff line change
Expand Up @@ -802,6 +802,8 @@ def __init__(self, model: str = "", claude_path: str = "claude", timeout: int =
# honors PATHEXT and returns the full claude.CMD path.
import shutil as _shutil
self.claude_path = _shutil.which(claude_path) or claude_path
self._safe_mode_supported: Optional[bool] = None
self.isolation_modes_used: set[str] = set()

# Known CLI error prefixes that indicate auth or config failures.
# When detected, we log a warning so the user doesn't mistake a
Expand Down Expand Up @@ -836,23 +838,55 @@ def _detect_cli_error(self, stdout: str, stderr: str) -> None:
self.last_call_error = combined[:500]
return

def _isolation_flag(self) -> str:
"""Select an isolated mode; a failed capability probe never permits replay."""
if os.environ.get("ANTHROPIC_API_KEY", "").strip():
mode = "bare"
else:
if self._safe_mode_supported is None:
self._safe_mode_supported = False
try:
with tempfile.TemporaryDirectory(prefix="skillopt_claude_help_") as cwd:
help_result = subprocess.run(
[self.claude_path, "--help"], capture_output=True, text=True,
creationflags=_NO_WINDOW, timeout=min(self.timeout, 10), cwd=cwd,
)
self._safe_mode_supported = help_result.returncode == 0 and bool(
re.search(r"(?m)^\s*--safe-mode(?:\s|$)", help_result.stdout or "")
)
except (OSError, subprocess.SubprocessError):
pass
if not self._safe_mode_supported:
from skillopt_sleep.staging import StagingError

raise StagingError(
"Claude replay refused: isolated measurement requires ANTHROPIC_API_KEY "
"for --bare or a CLI advertising --safe-mode. Without either, user-level "
"instructions and hooks may contaminate scores. Upgrade Claude Code, "
"configure an API key, or choose another backend."
)
mode = "safe-mode"
self.isolation_modes_used.add(mode)
return "--" + mode

def _call(self, prompt: str, *, max_tokens: int = 1024) -> str:
# Run ISOLATED so the ambient Claude Code environment does not leak into
# the optimizer/target call. Critically, the user's GLOBAL skills
# (~/.claude/skills) are injected regardless of cwd, so we must disable
# them explicitly — without this, reflect/attempt sometimes reply with a
# list of the user's installed skills instead of doing the task.
# --bare skip hooks, LSP, plugins (minimal mode)
# Only safe with ANTHROPIC_API_KEY auth;
# breaks subscription-token auth (#68).
# Requires ANTHROPIC_API_KEY (#68).
# --safe-mode skip user customizations with normal auth;
# admin-managed policy still applies.
# --disable-slash-commands disable all skills
# --disallowedTools '*' no tool use
# --exclude-dynamic-... drop per-machine cwd/env/memory/git sections
# cwd=<clean temp> no project CLAUDE.md
import tempfile
isolation_flag = self._isolation_flag()
cmd = [self.claude_path, "-p", "--output-format", "text"]
if os.environ.get("ANTHROPIC_API_KEY"):
cmd.append("--bare")
cmd.append(isolation_flag)
cmd += [
"--disable-slash-commands",
"--disallowedTools", "*",
Expand Down Expand Up @@ -923,8 +957,7 @@ def attempt_with_tools(self, task, skill, memory, tools):
"Return ONLY the final answer text."
)
cmd = [self.claude_path, "-p", "--output-format", "text"]
if os.environ.get("ANTHROPIC_API_KEY"):
cmd.append("--bare")
cmd.append(self._isolation_flag())
cmd += [
"--disable-slash-commands",
"--allowedTools", "Bash",
Expand Down
25 changes: 25 additions & 0 deletions skillopt_sleep/cycle.py
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,24 @@ def _make_backend_key(backend: Backend) -> str:
return f"{name}::{model}"


def _claude_isolation_modes(backend: Backend, *, reset: bool = False) -> dict[str, list[str]]:
"""Keep per-night mode evidence for direct and split-role backends."""
roles = {
"backend": backend,
"optimizer": getattr(backend, "optimizer", None),
"target": getattr(backend, "target", None),
}
modes = {}
for role, resolved in roles.items():
used = getattr(resolved, "isolation_modes_used", None)
if used is not None:
if reset:
used.clear()
elif used:
modes[role] = sorted(used)
return modes


def _check_model_change(
cfg: SleepConfig, state: SleepState, backend: Backend | None = None
) -> None:
Expand Down Expand Up @@ -697,6 +715,7 @@ def run_sleep_cycle(
project_dir=project,
)
_check_model_change(cfg, state, backend) # F16: warn if model changed between nights
_claude_isolation_modes(backend, reset=True)
night = state.begin_night(clock)
started = _now_iso(clock)
backend.preferences = cfg.get("preferences", "")
Expand Down Expand Up @@ -1073,6 +1092,11 @@ def budget_exhausted() -> bool:
row.reason = str(redact_secrets(row.reason))
report.skill_groups = group_rows

isolation_modes = _claude_isolation_modes(backend)
for role, modes in isolation_modes.items():
report.notes.append(
f"Claude replay isolation ({role}): " + ", ".join("--" + mode for mode in modes)
)
report.tokens_used = backend.tokens_used()
report.ended_at = _now_iso(clock)
if budget_skipped:
Expand Down Expand Up @@ -1152,6 +1176,7 @@ def budget_exhausted() -> bool:
json_safe({
"night": night,
"backend": cfg.get("backend"),
"claude_isolation_modes": isolation_modes,
"opencode_tool_replay": (
cfg.get("opencode_tool_replay", False) is True
),
Expand Down
3 changes: 2 additions & 1 deletion skillopt_sleep/multi_skill.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
from skillopt_sleep.backend import Backend, CursorBackendError
from skillopt_sleep.consolidate import ConsolidationResult, consolidate
from skillopt_sleep.handoff_backend import PendingCalls
from skillopt_sleep.staging import StagingError
from skillopt_sleep.types import SkillGroupReport, TaskRecord

CONSOLIDATED = "consolidated"
Expand Down Expand Up @@ -123,7 +124,7 @@ def consolidate_groups(
backend, list(group.tasks), group.skill, memory,
**group_kwargs,
)
except (PendingCalls, CursorBackendError):
except (PendingCalls, CursorBackendError, StagingError):
# These exceptions are cycle-level control flow, not isolated
# evidence about one group. Swallowing them can advance/save an
# incomplete handoff night or weaken Cursor's fail-closed path.
Expand Down
124 changes: 124 additions & 0 deletions tests/test_claude_replay_isolation.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
"""Offline command/report regressions; real CLI isolation is checked separately."""
import json
import os
import subprocess
from types import SimpleNamespace
from unittest import mock

import pytest

from skillopt_sleep.backend import ClaudeCliBackend, DualBackend, MockBackend
from skillopt_sleep.config import load_config
from skillopt_sleep.cycle import run_sleep_cycle
from skillopt_sleep.staging import StagingError
from skillopt_sleep.types import TaskRecord


def result(stdout="", returncode=0):
return SimpleNamespace(stdout=stdout, stderr="", returncode=returncode)


def test_oauth_safe_mode_is_cached_across_both_replay_paths(monkeypatch):
monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False)
backend = ClaudeCliBackend(claude_path="/synthetic/claude", timeout=5)
commands = []

def cli(command, **kwargs):
commands.append(command)
return result(" --safe-mode Disable customizations" if "--help" in command else "OK")

with mock.patch("skillopt_sleep.backend.subprocess.run", side_effect=cli):
assert backend._call("first") == "OK"
assert backend._call("second") == "OK"
answer, _ = backend.attempt_with_tools(TaskRecord(id="t", project="synthetic", intent="test"), "", "", ["search"])
assert answer == "OK"
assert sum("--help" in command for command in commands) == 1
replays = [command for command in commands if "-p" in command]
assert len(replays) == 3
assert all("--safe-mode" in command and "--bare" not in command for command in replays)


@pytest.mark.parametrize("help_result", [
result(" --other No safe mode here"),
result("This version does not support --safe-mode"),
result(" --safe-mode Disable customizations", returncode=1),
subprocess.TimeoutExpired("claude --help", 5),
FileNotFoundError("missing CLI"),
])
def test_unsupported_or_failed_probe_never_runs_an_unisolated_replay(monkeypatch, help_result):
monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False)
backend = ClaudeCliBackend(claude_path="/synthetic/claude", timeout=5)
with mock.patch("skillopt_sleep.backend.subprocess.run") as run:
if isinstance(help_result, Exception):
run.side_effect = help_result
else:
run.return_value = help_result
with pytest.raises(StagingError):
backend._call("first")
with pytest.raises(StagingError):
backend.attempt_with_tools(TaskRecord(id="t", project="synthetic", intent="test"), "", "", ["search"])
assert run.call_count == 1
assert run.call_args.args[0] == [backend.claude_path, "--help"]


def test_api_key_uses_bare_without_a_capability_probe(monkeypatch):
monkeypatch.setenv("ANTHROPIC_API_KEY", "synthetic-unit-test-key")
backend = ClaudeCliBackend(claude_path="/synthetic/claude", timeout=5)
with mock.patch("skillopt_sleep.backend.subprocess.run", return_value=result("OK")) as run:
assert backend._call("first") == "OK"
backend.attempt_with_tools(TaskRecord(id="t", project="synthetic", intent="test"), "", "", ["search"])
assert run.call_count == 2
assert all("--bare" in call.args[0] and "--safe-mode" not in call.args[0]
for call in run.call_args_list)


def test_switching_auth_selects_each_isolated_mode_without_reprobing(monkeypatch):
backend = ClaudeCliBackend(claude_path="/synthetic/claude", timeout=5)
commands = []

def cli(command, **kwargs):
commands.append(command)
return result(" --safe-mode Disable customizations" if "--help" in command else "OK")

with mock.patch("skillopt_sleep.backend.subprocess.run", side_effect=cli):
monkeypatch.setenv("ANTHROPIC_API_KEY", "synthetic-unit-test-key")
backend._call("api key")
monkeypatch.delenv("ANTHROPIC_API_KEY")
backend._call("oauth")
monkeypatch.setenv("ANTHROPIC_API_KEY", "synthetic-unit-test-key")
backend._call("api key again")
assert sum("--help" in command for command in commands) == 1
assert ["--bare" in command for command in commands if "-p" in command] == [True, False, True]


@pytest.mark.parametrize("split", [False, True])
def test_night_records_the_actual_isolation_mode_in_diagnostics_and_report(tmp_path, monkeypatch, split):
monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False)
project = tmp_path / "project"
project.mkdir()
cfg = load_config(invoked_project=str(project), projects="invoked", backend="claude",
claude_home=str(tmp_path / "synthetic-home"), auto_adopt=False,
multi_skill_report=False)
claude = ClaudeCliBackend(claude_path="/synthetic/claude", timeout=5)
backend = DualBackend(target=claude, optimizer=MockBackend()) if split else claude
tasks = [TaskRecord(id=str(i), project=str(project), intent="Reply OK", reference="OK",
reference_kind="exact", split="train" if i < 2 else "val") for i in range(4)]

def cli(command, **kwargs):
return result(" --safe-mode Disable customizations" if "--help" in command else "OK")

with mock.patch("skillopt_sleep.backend.subprocess.run", side_effect=cli):
outcome = run_sleep_cycle(cfg, seed_tasks=tasks, backend=backend)
with open(os.path.join(outcome.staging_dir, "diagnostics.json")) as handle:
diagnostics = json.load(handle)
assert diagnostics["claude_isolation_modes"] == {"target" if split else "backend": ["safe-mode"]}
for name in ["report.json", "report.md"]:
with open(os.path.join(outcome.staging_dir, name)) as handle:
assert "--safe-mode" in handle.read()

# The same tasks are already consumed: no new calls means no new mode evidence.
monkeypatch.setenv("ANTHROPIC_API_KEY", "synthetic-unit-test-key")
with mock.patch("skillopt_sleep.backend.subprocess.run", side_effect=cli):
second = run_sleep_cycle(cfg, seed_tasks=tasks, backend=backend)
with open(os.path.join(second.staging_dir, "diagnostics.json")) as handle:
assert json.load(handle)["claude_isolation_modes"] == {}
2 changes: 2 additions & 0 deletions tests/test_cli_backend_cache.py
Original file line number Diff line number Diff line change
Expand Up @@ -306,6 +306,7 @@ def test_attempt_with_tools_sets_call_local_delta(monkeypatch):
from skillopt_sleep.backend import ClaudeCliBackend

b = ClaudeCliBackend(model="", claude_path="claude", timeout=10)
monkeypatch.setenv("ANTHROPIC_API_KEY", "test-key")

def fake_run(*args, **kwargs):
return SimpleNamespace(returncode=0, stdout="ok\n", stderr="")
Expand All @@ -328,6 +329,7 @@ def test_dual_backend_attempt_with_tools_sets_target_delta(monkeypatch):
target = ClaudeCliBackend(model="", claude_path="claude", timeout=10)
optimizer = ClaudeCliBackend(model="", claude_path="claude", timeout=10)
db = DualBackend(target=target, optimizer=optimizer)
monkeypatch.setenv("ANTHROPIC_API_KEY", "test-key")

def fake_run(*args, **kwargs):
return SimpleNamespace(returncode=0, stdout="ok\n", stderr="")
Expand Down
Loading
Loading