Require skill delivery for PTT updates

Require skill delivery for PTT updates
This commit is contained in:
Dan
2026-07-18 19:59:30 -04:00
committed by GitHub
6 changed files with 229 additions and 12 deletions
+14
View File
@@ -34,6 +34,15 @@ RECORD_PTT_SCHEMA = {
"id": {"type": "string"},
"status": {"type": "string"},
"note": {"type": "string"},
"skill": {"type": "string", "description": "Required selected Violin skill"},
"technique": {
"type": "string",
"description": "Required concrete technique for this task",
},
"hypothesis_id": {
"type": "string",
"description": "Required for hypothesis-driven phases",
},
"title": {
"type": "string",
"description": "Required when explicitly creating a new PTT task",
@@ -130,6 +139,11 @@ REVIEW_BATCH_SCHEMA = {
"enum": ["[~]", "[x]", "[!]", "[-]"],
},
"note": {"type": "string", "description": "Truthful result/evidence review"},
"skill": {"type": "string", "description": "Required selected review skill"},
"hypothesis_id": {
"type": "string",
"description": "Required for hypothesis-driven phases",
},
"finding": {
"type": "object",
"description": "Optional structured finding derived only from this batch",
+116
View File
@@ -2,6 +2,7 @@
from __future__ import annotations
import hashlib
import json
import os
import re
@@ -21,6 +22,7 @@ from .adapters import (
from .command import CheckCommandArgs
from .history import history_contains
from .phases import Phase, requires_hypothesis, suppresses_heartbeat
from .skill_receipts import HermesSkillViewAdapter, bind_task, complete_delivery, prepare_delivery
from .targets import resolve_target
# ---------------------------------------------------------------------------
@@ -93,13 +95,72 @@ def handle_record_ptt(a, **kwargs):
task = a.get("id")
note = (a.get("note") or "").strip()
status = a.get("status", "[~]")
skill = str(a.get("skill") or "").strip()
technique = str(a.get("technique") or "").strip()
if not task or not note:
raise ValueError("task id and non-empty lifecycle note required")
if not skill or not technique:
raise ValueError("skill and technique are required before a PTT update")
if pending:
raise ValueError(
"a target batch is pending; use violin_review_batch instead of violin_record_ptt"
)
selected = next((item for item in doc if item.id == task), None)
selected_phase = selected.phase if selected else str(a.get("phase") or "RECON")
try:
phase = ptt.normalize_phase(selected_phase)
except ValueError as exc:
raise ValueError(f"PTT task {task!r} must sit below a valid Phase heading") from exc
hypothesis_id = str(a.get("hypothesis_id") or "").strip()
if requires_hypothesis(phase) and not hypothesis_id:
raise ValueError(f"hypothesis_id is required for {phase.value} PTT work")
vulnerability_class = ""
if hypothesis_id:
normalized = hypothesis_id.removeprefix("H-").lstrip("0") or "0"
matched = next(
(
h
for h in hypotheses.parse_hypotheses(_eng_path(eng_dir) / "hypotheses.md")
if h.id.lstrip("0") == normalized
),
None,
)
vulnerability_class = matched.vuln_class if matched else ""
digest = "sha256:" + hashlib.sha256(f"policy:{skill}".encode()).hexdigest()
reservation = prepare_delivery(
eng_dir,
session_id=state.resolve_session_id(eng_dir) or "ptt",
skill=skill,
bundle_digest=digest,
phase=phase.value,
vulnerability_class=vulnerability_class or None,
)
if reservation.owner:
viewed = HermesSkillViewAdapter().view(skill, task_id=task)
completed = complete_delivery(eng_dir, reservation, viewed)
return _json(
"skill_prepared" if completed.status == "delivered" else "skill_unavailable",
transition_applied=False,
skill={
"name": skill,
"digest": digest,
"content": viewed.content,
"error": viewed.error,
},
)
if reservation.status == "preparing":
return _json(
"skill_preparing", transition_applied=False, skill={"name": skill, "digest": digest}
)
binding = bind_task(
eng_dir,
task_id=task,
delivery_id=reservation.id,
hypothesis_id=hypothesis_id,
technique=technique,
)
note = _with_skill_token(note, skill, digest)
if not any(item.id == task for item in doc):
created = ptt.create_task(
_eng_path(eng_dir) / "state" / "ptt.md",
@@ -112,6 +173,9 @@ def handle_record_ptt(a, **kwargs):
if status == "[ ]":
return _json("ok", task_id=created.id, task_created=True)
existing = next((item for item in doc if item.id == task), None)
if existing and existing.status == "[~]" and status == "[~]":
ptt.update_task(_eng_path(eng_dir) / "state" / "ptt.md", task, "[~]", note)
return _json("ok", task_id=task, task_refreshed=True, binding=binding)
if existing and status in {"[x]", "[-]"}:
if existing.status != "[~]":
raise ValueError("only the active [~] task may be closed outside a batch")
@@ -120,6 +184,13 @@ def handle_record_ptt(a, **kwargs):
return _start_ptt_task(_eng_path(eng_dir) / "state" / "ptt.md", doc, task, status, note)
def _with_skill_token(note: str, skill: str, digest: str) -> str:
"""Keep exactly one replaceable selection token in a PTT note."""
token = f"[skill:{skill}@{digest}]"
stripped = re.sub(r"\s*\[skill:[^\]]+\]", "", note).strip()
return f"{stripped} {token}".strip()
def _start_ptt_task(ptt_path: Path, tasks, task_id: str, status: str, note: str) -> str:
"""Arm one untouched, phase-bound task before the first target command."""
@@ -275,6 +346,51 @@ def handle_review_batch(a, **kwargs):
finding_path=None,
message="nothing pending",
)
skill = str(a.get("skill") or "").strip()
if skill:
tasks = ptt.parse_ptt(engagement / "state" / "ptt.md")
task_id = str(a.get("id") or "").strip()
task = next((item for item in tasks if item.id == task_id), None)
if task is None:
raise ValueError(f"batch task {task_id!r} is missing from the PTT")
hypothesis_id = str(a.get("hypothesis_id") or "").strip()
phase = ptt.normalize_phase(task.phase)
if requires_hypothesis(phase) and not hypothesis_id:
raise ValueError(f"hypothesis_id is required for {phase.value} batch review")
digest = "sha256:" + hashlib.sha256(f"policy:{skill}".encode()).hexdigest()
reservation = prepare_delivery(
engagement,
session_id=state.resolve_session_id(engagement) or "review",
skill=skill,
bundle_digest=digest,
phase=phase.value,
)
if reservation.owner:
viewed = HermesSkillViewAdapter().view(skill, task_id=task_id)
completed = complete_delivery(engagement, reservation, viewed)
return _json(
"skill_prepared"
if completed.status == "delivered"
else "skill_unavailable",
transition_applied=False,
released=False,
skill={
"name": skill,
"digest": digest,
"content": viewed.content,
"error": viewed.error,
},
)
if reservation.status == "preparing":
return _json("skill_preparing", transition_applied=False, released=False)
bind_task(
engagement,
task_id=task_id,
delivery_id=reservation.id,
hypothesis_id=hypothesis_id,
technique="batch-review",
)
a = {**a, "note": _with_skill_token(str(a.get("note") or ""), skill, digest)}
context = _validate_review_batch(a, pending)
finding_result = None
finding = context["finding"]
+22 -3
View File
@@ -4,6 +4,8 @@ from __future__ import annotations
import json
import os
import time
import uuid
from contextlib import contextmanager
from datetime import UTC, datetime
from pathlib import Path
@@ -114,9 +116,16 @@ def read_json(path: Path) -> dict[str, Any]:
def atomic_json(path: Path, data: dict[str, Any]) -> None:
"""Write JSON atomically by replacing a temporary swap file."""
path.parent.mkdir(parents=True, exist_ok=True)
tmp = path.with_suffix(path.suffix + ".tmp")
tmp = path.with_name(f"{path.name}.{uuid.uuid4().hex}.tmp")
tmp.write_text(json.dumps(data, indent=2, sort_keys=True), encoding="utf-8")
tmp.replace(path)
for attempt in range(5):
try:
tmp.replace(path)
return
except PermissionError:
if attempt == 4:
raise
time.sleep(0.02 * (attempt + 1))
def mutate_json(path: Path, mutation) -> Any:
@@ -357,7 +366,17 @@ def tick_message(eng_dir: str | Path) -> int:
data["messages"] = data.get("messages", 0) + 1
return data["messages"]
return mutate_json(path, tick)
# Windows can briefly deny the replace/read sequence immediately after a
# prior hook writes this same file. Lifecycle hooks intentionally do not
# fail the model turn, so retry here rather than silently dropping a tick.
for attempt in range(3):
try:
return mutate_json(path, tick)
except OSError:
if attempt == 2:
raise
time.sleep(0.02 * (attempt + 1))
raise RuntimeError("unreachable")
def record_ok_check(eng_dir: str | Path, command: str, phase: str) -> None:
+29 -2
View File
@@ -397,7 +397,14 @@ def test_exploitation_requires_cve_and_exploit_research_attempts(tmp_path):
assert not allowed.errors, allowed.errors
def test_record_ptt_can_start_pristine_task(tmp_path):
def test_record_ptt_can_start_pristine_task(tmp_path, monkeypatch):
from plugins.violin_guard.skill_receipts import SkillViewResult
monkeypatch.setattr(
TOOLS,
"HermesSkillViewAdapter",
lambda: type("Ready", (), {"view": lambda *_a, **_k: SkillViewResult(True, "skill")})(),
)
skill_file = tmp_path / ".skill-loaded-ts"
eng = _init_e2e(tmp_path, skill_file)
ptt_path = eng / "state" / "ptt.md"
@@ -408,7 +415,27 @@ def test_record_ptt_can_start_pristine_task(tmp_path):
result = json.loads(
TOOLS.handle_record_ptt(
{"eng_dir": str(eng), "id": "PT-010", "status": "[~]", "note": "Start recon"}
{
"eng_dir": str(eng),
"id": "PT-010",
"status": "[~]",
"note": "Start recon",
"skill": "pentest",
"technique": "recon",
}
)
)
assert result["status"] == "skill_prepared", result
result = json.loads(
TOOLS.handle_record_ptt(
{
"eng_dir": str(eng),
"id": "PT-010",
"status": "[~]",
"note": "Start recon",
"skill": "pentest",
"technique": "recon",
}
)
)
assert result["status"] == "ok", result
+40 -6
View File
@@ -12,10 +12,12 @@ from plugins.violin_guard import (
hypotheses,
ptt,
service,
state,
)
from plugins.violin_guard.command import CheckCommandArgs, CheckResult, check_scope_authorization
from plugins.violin_guard.history import append_history, check_history_staleness
from plugins.violin_guard.phases import Phase
from plugins.violin_guard.skill_receipts import SkillViewResult
from plugins.violin_guard.targets import check_scope_targets
@@ -56,7 +58,14 @@ def test_wildcard_scope_allows_subdomains(tmp_path: Path) -> None:
).errors
def test_ptt_heading_parenthetical_and_explicit_task_create_close(tmp_path: Path) -> None:
def test_ptt_heading_parenthetical_and_explicit_task_create_close(
tmp_path: Path, monkeypatch
) -> None:
monkeypatch.setattr(
service,
"HermesSkillViewAdapter",
lambda: type("Ready", (), {"view": lambda *_a, **_k: SkillViewResult(True, "skill")})(),
)
eng = _engagement(tmp_path)
ptt_path = eng / "state" / "ptt.md"
ptt_path.write_text(
@@ -75,15 +84,40 @@ def test_ptt_heading_parenthetical_and_explicit_task_create_close(tmp_path: Path
"title": "extra check",
"phase": "recon",
"note": "planned",
"skill": "pentest",
"technique": "recon",
}
)
)
assert created["status"] == "skill_prepared"
created = json.loads(
service.handle_record_ptt(
{
"eng_dir": str(eng),
"id": "PT-900",
"status": "[ ]",
"title": "extra check",
"phase": "recon",
"note": "planned",
"skill": "pentest",
"technique": "recon",
}
)
)
assert created["task_created"] is True
closed = json.loads(
service.handle_record_ptt(
{"eng_dir": str(eng), "id": "PT-010", "status": "[x]", "note": "done"}
)
)
# A fresh session must prepare again; do not rely on cross-task reuse here.
state.record_session_id(eng, "close-session")
close_args = {
"eng_dir": str(eng),
"id": "PT-010",
"status": "[x]",
"note": "done",
"skill": "pentest",
"technique": "recon",
}
closed = json.loads(service.handle_record_ptt(close_args))
assert closed["status"] == "skill_prepared"
closed = json.loads(service.handle_record_ptt(close_args))
assert closed["task_closed"] is True
+8 -1
View File
@@ -33,7 +33,14 @@ def test_record_ptt_refuses_to_reconcile_a_pending_batch(tmp_path: Path) -> None
)
result = json.loads(
service.handle_record_ptt(
{"eng_dir": str(eng), "id": "PT-011", "status": "[~]", "note": "review"}
{
"eng_dir": str(eng),
"id": "PT-011",
"status": "[~]",
"note": "review",
"skill": "pentest",
"technique": "recon",
}
)
)
assert result["status"] == "error"