Skip to content

Commit 9639719

Browse files
authored
Merge pull request #203 from Yif-Yang/fix/aug06-post-merge-hardening
fix(sleep): harden merged outcome, grouping, and Copilot paths
2 parents 7c52718 + 41827d8 commit 9639719

6 files changed

Lines changed: 83 additions & 7 deletions

File tree

skillopt_sleep/harvest_copilot_cli.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
_is_meta_prompt,
2828
_project_matches,
2929
)
30+
from skillopt_sleep.staging import redact_secrets
3031
from skillopt_sleep.types import SessionDigest
3132

3233
# Bound per-session text so one pathological session cannot dominate a night's
@@ -46,7 +47,9 @@ def default_session_store() -> str:
4647
def _clip(text: Any) -> str:
4748
if not isinstance(text, str):
4849
return ""
49-
text = text.strip()
50+
# Redact before truncating: clipping first could retain and persist only a
51+
# secret fragment that no longer matches the shared redaction patterns.
52+
text = str(redact_secrets(text)).strip()
5053
return text[:_MAX_TEXT_CHARS]
5154

5255

skillopt_sleep/judges.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -74,12 +74,13 @@ def _is_refusal(response: str) -> bool:
7474
# Strip leading markdown markers -- blockquote (>), list bullets (-, *),
7575
# numbered items (1. / 1)), emphasis and headings -- BEFORE bounding the
7676
# head, so a refusal cannot hide behind >160 marker characters.
77-
head = re.sub(r"^(?:[>\-*_#\s]|\d+[.)])+", "", text.lower())[:160]
77+
content = re.sub(r"^(?:[>\-*_#\s]|\d+[.)])+", "", text)
78+
head = content[:160].lower()
7879
if not any(head.startswith(p) for p in _REFUSAL_PREFIXES):
7980
return False
8081
# A long response that opens with an abstention still did the work of
8182
# explaining why; only terse dead-ends are refusals.
82-
return len(text) < 600
83+
return len(content) < 600
8384

8485

8586
def _check(op: str, arg: Any, response: str,

skillopt_sleep/multi_skill.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,10 @@ def consolidate_groups(
7070
``memory`` is the shared agent memory and is passed through read-only: group
7171
runs evolve skills only, so no group can rewrite another group's memory.
7272
"""
73+
# This wrapper's contract is stricter than consolidate(): shared memory is
74+
# always read-only. Override a caller-supplied value instead of passing a
75+
# duplicate keyword (which would otherwise turn the group into a failure).
76+
consolidate_kwargs["evolve_memory"] = False
7377
out: Dict[str, GroupConsolidation] = {}
7478
for group in groups:
7579
name = (group.skill_name or "").strip()
@@ -84,7 +88,7 @@ def consolidate_groups(
8488
try:
8589
result = consolidate_fn(
8690
backend, list(group.tasks), group.skill, memory,
87-
evolve_memory=False, **consolidate_kwargs,
91+
**consolidate_kwargs,
8892
)
8993
except Exception as exc: # one group's failure must not abort the night
9094
out[name] = GroupConsolidation(

tests/test_harvest_copilot_cli.py

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,45 @@ def test_maps_session_and_turn_fields(tmp_path) -> None:
9090
assert d.raw_path.endswith("#s1")
9191

9292

93+
def test_redacts_user_and_assistant_secrets_before_harvesting(tmp_path) -> None:
94+
user_secret = "sk-abcdefghijklmnopqrstuvwxyz1234567890"
95+
assistant_secret = "super-secret-value-123456"
96+
path = _store(
97+
tmp_path,
98+
[("s1", r"C:\proj", "repo", "main", "2026-01-01 10:00:00", "2026-01-01 10:30:00")],
99+
[
100+
(
101+
"s1",
102+
0,
103+
f"Use Authorization: Bearer {user_secret} for this task",
104+
f"Configured api_key={assistant_secret}",
105+
"2026-01-01 10:00:00",
106+
)
107+
],
108+
)
109+
110+
[digest] = harvest_copilot_cli(path, scope="all")
111+
harvested = "\n".join(digest.user_prompts + digest.assistant_finals)
112+
assert user_secret not in harvested
113+
assert assistant_secret not in harvested
114+
assert "[REDACTED" in harvested
115+
116+
117+
def test_redacts_secrets_before_text_is_clipped(tmp_path) -> None:
118+
secret = "sk-abcdefghijklmnopqrstuvwxyz1234567890"
119+
# The token begins just before the 4000-char boundary. Clipping first would
120+
# leave a secret fragment that no longer matches the redaction pattern.
121+
prompt = "x" * (4000 - 5) + secret
122+
path = _store(
123+
tmp_path,
124+
[("s1", r"C:\proj", "repo", "main", "2026-01-01 10:00:00", "2026-01-01 10:30:00")],
125+
[("s1", 0, prompt, "done", "2026-01-01 10:00:00")],
126+
)
127+
128+
[digest] = harvest_copilot_cli(path, scope="all")
129+
assert "sk-" not in digest.user_prompts[0]
130+
131+
93132
def test_engine_self_calls_are_filtered(tmp_path) -> None:
94133
# SkillOpt's own Copilot backend writes to this same store; harvesting them
95134
# would train the engine on its own output.
@@ -269,4 +308,3 @@ def _boom(_store_path):
269308

270309
monkeypatch.setattr("skillopt_sleep.harvest_copilot_cli._connect", _boom)
271310
assert harvest_copilot_cli(path, scope="all") == []
272-

tests/test_outcome_judges.py

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,23 @@ def test_no_refusal_passes_on_substantive_answer() -> None:
5858
assert score_rule_judge(judge, "Here is the context you asked for: ...")[0] == 1.0
5959

6060

61+
def test_no_refusal_ignores_markdown_prefix_length() -> None:
62+
judge = {"kind": "rule", "checks": [{"op": "no_refusal"}]}
63+
response = "> " * 350 + "I cannot help with that."
64+
assert len(response) >= 600
65+
assert score_rule_judge(judge, response)[0] == 0.0
66+
67+
68+
def test_no_refusal_length_is_measured_before_unicode_lowercasing() -> None:
69+
judge = {"kind": "rule", "checks": [{"op": "no_refusal"}]}
70+
# U+0130 lowercases to two code points. Case normalization must not turn a
71+
# short refusal into an apparently substantive response over 600 chars.
72+
response = "I cannot help. " + "İ" * 300
73+
assert len(response) < 600
74+
assert len(response.lower()) >= 600
75+
assert score_rule_judge(judge, response)[0] == 0.0
76+
77+
6178
def test_no_refusal_accepts_a_refusal_that_still_does_the_work() -> None:
6279
# An abstention that explains what was searched and what is missing is a
6380
# useful answer, not a dead end.
@@ -383,5 +400,3 @@ def test_char_bound_rejects_non_integers(bad) -> None:
383400
with pytest.raises((ValueError, TypeError)):
384401
char_bound(bad)
385402

386-
387-

tests/test_sleep_multi_skill.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -163,6 +163,21 @@ def _fake(backend, tasks, skill, memory, **kwargs):
163163
self.assertEqual(seen["memory"], "# shared memory\n")
164164
self.assertFalse(seen["evolve_memory"])
165165

166+
def test_caller_cannot_override_shared_memory_isolation(self):
167+
seen = {}
168+
169+
def _fake(backend, tasks, skill, memory, **kwargs):
170+
seen.update(kwargs)
171+
return consolidate(backend, tasks, skill, memory, **kwargs)
172+
173+
outcomes = consolidate_groups(
174+
MockBackend(),
175+
[SkillGroup("research-skill", set_learned("", []), _tasks(researcher_persona))],
176+
consolidate_fn=_fake, edit_budget=4, night=1, evolve_memory=True,
177+
)
178+
self.assertEqual(outcomes["research-skill"].status, CONSOLIDATED)
179+
self.assertFalse(seen["evolve_memory"])
180+
166181
def test_accepted_group_skills_lists_only_accepted_updates(self):
167182
outcomes = {
168183
"kept": GroupConsolidation(

0 commit comments

Comments
 (0)